From d880b64d641da9150d2c57fc55e608285705f035 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:29:31 +0200 Subject: [PATCH] =?UTF-8?q?test:=20audit=202026-08-19=20=E2=80=94=20fix=20?= =?UTF-8?q?stale=20tests,=20close=20coverage=20gaps=20(#1397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 6 + Client/tauri-client/src-tauri/src/commands.rs | 109 +- .../tauri-client/src-tauri/src/credentials.rs | 28 + .../src-tauri/src/secret_store.rs | 59 + Client/tauri-client/src-tauri/src/tofu.rs | 31 + .../src-tauri/src/update_commands.rs | 35 + Client/tauri-client/src/lib/rate-limiter.ts | 11 +- Client/tauri-client/src/lib/ws.ts | 18 +- .../tauri-client/tests/browser/smoke.test.ts | 73 +- .../tests/unit/auth.store.test.ts | 42 + .../tests/unit/channel-sidebar.test.ts | 42 + .../tests/unit/connect-page.test.ts | 25 + .../tests/unit/credentials.test.ts | 58 +- .../tests/unit/dispatcher.test.ts | 310 +- .../tests/unit/e2eeCrypto.test.ts | 143 +- .../tests/unit/host-validation.test.ts | 75 + .../tauri-client/tests/unit/identity.test.ts | 130 +- .../tests/unit/livekit-e2ee.test.ts | 177 + .../tests/unit/log-persistence.test.ts | 9 +- Client/tauri-client/tests/unit/media.test.ts | 21 +- .../tests/unit/messages.store.test.ts | 230 + .../tests/unit/permissions.test.ts | 49 + .../tests/unit/rate-limiter.test.ts | 28 +- .../tauri-client/tests/unit/renderers.test.ts | 32 +- .../tests/unit/sidebar-area.test.ts | 83 + .../tests/unit/user-profile-popup.test.ts | 104 +- .../tests/unit/voice.store.test.ts | 403 +- .../tauri-client/tests/unit/ws-cert.test.ts | 68 + .../tests/unit/ws-lifecycle.test.ts | 88 +- .../tests/unit/ws-messaging.test.ts | 21 +- Client/tauri-client/vitest.config.ts | 6 +- Server/admin/channels_archive_voice_test.go | 38 + Server/admin/export_test.go | 10 + Server/admin/handlers_channels.go | 25 +- Server/api/clientip_test.go | 33 + Server/api/constants_test.go | 86 +- Server/api/middleware_test.go | 46 + Server/api/waf_test.go | 62 + Server/auth/totp_encrypt_test.go | 252 + Server/db/migrate_upgrade_test.go | 66 +- Server/db/session_expiry_test.go | 69 +- Server/updater/updater_test.go | 116 + Server/ws/deps.go | 11 +- Server/ws/export_test.go | 16 +- Server/ws/handlers_command.go | 3 +- Server/ws/handlers_command_gate_test.go | 192 + Server/ws/hub.go | 10 +- Server/ws/hub_broadcast_test.go | 133 +- Server/ws/livekit_test.go | 118 + Server/ws/livekit_webhook_joined_test.go | 37 + Server/ws/voice_audience_test.go | 71 + Server/ws/voice_handlers_test.go | 81 +- Server/ws/voice_moderation_test.go | 49 + Server/ws/voice_rate_limits_test.go | 133 + docs/audit-test-coverage-2026-08-19.md | 163 + graphify-out/.graphify_labels.json | 197 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/GRAPH_REPORT.md | 1094 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 381401 ++++++++------- graphify-out/manifest.json | 991 +- 61 files changed, 206893 insertions(+), 181134 deletions(-) create mode 100644 Client/tauri-client/tests/unit/host-validation.test.ts create mode 100644 Server/auth/totp_encrypt_test.go create mode 100644 Server/ws/handlers_command_gate_test.go create mode 100644 Server/ws/voice_rate_limits_test.go create mode 100644 docs/audit-test-coverage-2026-08-19.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 764cd408..64a6d354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,6 +283,12 @@ jobs: - name: Run Playwright tests run: npx playwright test --config=playwright.config.ts + # Browser-mode unit tests (tests/browser/): real AudioContext + WASM + # behind the same Chromium, so the noise-suppression pipeline has a + # test that actually runs somewhere (test-audit 2026-08-19, T-22). + - name: Run browser-mode unit tests + run: npm run test:browser + - name: Upload Playwright report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs index 0c876d92..9679ef79 100644 --- a/Client/tauri-client/src-tauri/src/commands.rs +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use serde_json::Value; use tauri_plugin_store::StoreExt; use crate::constants::{CERTS_STORE, IDENTITY_PINS_STORE, SETTINGS_STORE}; +use crate::ws_proxy::is_valid_cert_fingerprint; /// Maximum length for a settings key to prevent denial-of-service. const MAX_SETTINGS_KEY_LEN: usize = 128; @@ -75,15 +76,13 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result // Certificate fingerprint commands // --------------------------------------------------------------------------- -#[tauri::command] -pub fn store_cert_fingerprint( - app: tauri::AppHandle, - host: String, - fingerprint: String, -) -> Result<(), String> { - // Normalize to lowercase for consistent comparison with ws_proxy fingerprints - let fingerprint = fingerprint.to_lowercase(); - +/// Validate the arguments of a cert-pin write. +/// +/// Split out of `store_cert_fingerprint` so the guard — the only thing standing +/// between a caller and a trusted cert pin — is reachable from unit tests +/// without a Tauri runtime. The fingerprint half is the same check the +/// `accept_cert_fingerprint` path uses, so the two pin writers cannot drift. +fn validate_cert_pin(host: &str, fingerprint: &str) -> Result<(), String> { if host.is_empty() || host.len() > 253 { return Err("host must be 1-253 characters".into()); } @@ -94,20 +93,23 @@ pub fn store_cert_fingerprint( if fingerprint.is_empty() { return Err("fingerprint must not be empty".into()); } - - // Validate SHA-256 colon-hex format: "aa:bb:cc:..." (95 chars, 32 hex pairs) - if fingerprint.len() != 95 { + // SHA-256 colon-hex format: "aa:bb:cc:..." (95 chars, 32 hex pairs) + if !is_valid_cert_fingerprint(fingerprint) { return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into()); } - for (i, ch) in fingerprint.chars().enumerate() { - if i % 3 == 2 { - if ch != ':' { - return Err("fingerprint must use colon-separated hex pairs".into()); - } - } else if !ch.is_ascii_hexdigit() { - return Err("fingerprint contains invalid hex character".into()); - } - } + Ok(()) +} + +#[tauri::command] +pub fn store_cert_fingerprint( + app: tauri::AppHandle, + host: String, + fingerprint: String, +) -> Result<(), String> { + // Normalize to lowercase for consistent comparison with ws_proxy fingerprints + let fingerprint = fingerprint.to_lowercase(); + + validate_cert_pin(&host, &fingerprint)?; let store = app.store(CERTS_STORE).map_err(|e| { log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}")) @@ -311,24 +313,63 @@ mod tests { assert!(!is_settings_key_allowed("owncordNOCOLON")); } + /// A well-formed SHA-256 colon-hex fingerprint (32 pairs, 95 chars). + const VALID_FP: &str = + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"; + #[test] - fn fingerprint_validation_accepts_valid() { - let valid = "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"; - assert_eq!(valid.len(), 95); - // Validation logic: length 95, hex digits at non-colon positions, colons at every 3rd - for (i, ch) in valid.chars().enumerate() { - if i % 3 == 2 { - assert_eq!(ch, ':'); - } else { - assert!(ch.is_ascii_hexdigit()); - } + fn cert_pin_accepts_well_formed_args() { + assert!(validate_cert_pin("chat.example.com", VALID_FP).is_ok()); + // Uppercase hex is accepted (the command lowercases before validating). + assert!(validate_cert_pin("chat.example.com", &VALID_FP.to_uppercase()).is_ok()); + // Host with a port, and a bracketed IPv6 literal. + assert!(validate_cert_pin("192.168.1.10:8443", VALID_FP).is_ok()); + assert!(validate_cert_pin("[fe80::1]:8443", VALID_FP).is_ok()); + } + + #[test] + fn cert_pin_rejects_malformed_fingerprints() { + // Same length and charset, colon one position off. + let mut misplaced_colon = VALID_FP.to_owned(); + misplaced_colon.replace_range(2..4, "a:"); + // Still 95 chars, but padded with whitespace instead of hex. + let leading_space = format!(" {}", &VALID_FP[..94]); + let trailing_space = format!("{} ", &VALID_FP[1..]); + + let cases: &[(&str, &str)] = &[ + ("empty", ""), + ("too short", &VALID_FP[..92]), + ("too long", "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:00"), + ("non-hex digit", "zz:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"), + ("dash separator", "aa-bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"), + ("misplaced colon", &misplaced_colon), + ("leading space", &leading_space), + ("trailing space", &trailing_space), + ]; + for (name, fp) in cases { + assert!( + validate_cert_pin("chat.example.com", fp).is_err(), + "expected {name} fingerprint to be rejected: {fp:?}" + ); } } #[test] - fn fingerprint_validation_rejects_wrong_length() { - let short = "aa:bb:cc"; - assert_ne!(short.len(), 95); + fn cert_pin_rejects_malformed_hosts() { + let cases: &[(&str, String)] = &[ + ("empty", String::new()), + ("too long", "a".repeat(254)), + ("space", "chat example.com".into()), + ("path traversal", "chat.example.com/../evil".into()), + ("underscore", "chat_example.com".into()), + ("newline", "chat.example.com\n".into()), + ]; + for (name, host) in cases { + assert!( + validate_cert_pin(host, VALID_FP).is_err(), + "expected {name} host to be rejected: {host:?}" + ); + } } #[test] diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index c0fdbb22..6788827b 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -454,4 +454,32 @@ mod tests { "two credential-store commands ran their critical section concurrently" ); } + + /// `with_credential_lock`'s doc comment promises that poisoning is + /// recovered from rather than propagated, so a panic inside one + /// credential command cannot permanently wedge every later credential + /// operation for the rest of the process. Prove it: panic while holding + /// the lock on a spawned thread (which poisons `CREDENTIAL_LOCK`), then + /// confirm a later `with_credential_lock` call still runs its closure + /// instead of panicking on the poisoned mutex. + #[test] + fn with_credential_lock_recovers_from_a_poisoned_guard() { + use std::thread; + + let poisoning = thread::spawn(|| { + with_credential_lock(|| { + panic!("boom"); + }); + }); + assert!( + poisoning.join().is_err(), + "expected the spawned thread to panic while holding the lock" + ); + + assert_eq!( + with_credential_lock(|| 42), + 42, + "with_credential_lock must recover from a poisoned mutex, not propagate it" + ); + } } diff --git a/Client/tauri-client/src-tauri/src/secret_store.rs b/Client/tauri-client/src-tauri/src/secret_store.rs index 0b5e8cad..b03aa6b2 100644 --- a/Client/tauri-client/src-tauri/src/secret_store.rs +++ b/Client/tauri-client/src-tauri/src/secret_store.rs @@ -628,6 +628,65 @@ mod tests { assert!(cleared.get(), "a recovered machine must clear any stale fallback copy"); } + #[test] + fn set_with_purges_the_keyring_entry_when_the_read_back_returns_a_different_secret() { + // The bug: get() reads the keyring first, so a foreign value left in + // place would shadow the fallback copy written below — handing the + // caller an identity key whose public half was never published. + use std::cell::Cell; + let deleted = Cell::new(false); + let fallback_written = Cell::new(false); + let result = set_with( + "acct", + "mine", + |_, _| Ok(()), + |_| Ok(Some("someone-elses-secret".to_string())), + |_| { + deleted.set(true); + Ok(()) + }, + |_, s| { + assert_eq!(s, "mine"); + fallback_written.set(true); + Ok(()) + }, + |_| panic!("must not clear the fallback copy it just wrote"), + ); + assert_eq!(result, Ok(FALLBACK_BACKEND)); + assert!( + deleted.get(), + "a mismatched keyring entry must be purged, not left to shadow the fallback" + ); + assert!(fallback_written.get(), "the secret must still land in the fallback"); + } + + #[test] + fn set_with_falls_back_when_the_read_back_reports_no_entry() { + // The shipped keyring-mock defect: set_password returns Ok(()) and the + // very next get_password returns nothing. A write that does not read + // back is not a write. + use std::cell::Cell; + let fallback_written = Cell::new(false); + let result = set_with( + "acct", + "secret", + |_, _| Ok(()), + |_| Ok(None), + |_| panic!("nothing round-tripped, so there is no entry to delete"), + |_, s| { + assert_eq!(s, "secret"); + fallback_written.set(true); + Ok(()) + }, + |_| panic!("must not clear the fallback copy it just wrote"), + ); + assert_eq!(result, Ok(FALLBACK_BACKEND)); + assert!( + fallback_written.get(), + "a write that does not read back must land in the fallback" + ); + } + // -- delete_with: finding "delete must not report success while the // fallback copy survives on disk to resurrect a deleted secret" -- diff --git a/Client/tauri-client/src-tauri/src/tofu.rs b/Client/tauri-client/src-tauri/src/tofu.rs index 9d4f9d25..0762d33b 100644 --- a/Client/tauri-client/src-tauri/src/tofu.rs +++ b/Client/tauri-client/src-tauri/src/tofu.rs @@ -464,6 +464,37 @@ mod tests { ); } + // ── CaptureVerifier ────────────────────────────────────────────────────── + + // The whole post-handshake TOFU pin depends on CaptureVerifier recording + // the LEAF cert, not an intermediate — that's what the safety comment at + // the top of the impl asserts. Prove it: feed it a leaf plus a different + // intermediate and check which fingerprint lands in the shared cell. + #[test] + fn capture_verifier_records_leaf_not_intermediate() { + use rustls::client::danger::ServerCertVerifier; + + let (verifier, captured) = CaptureVerifier::new(); + let leaf = rustls::pki_types::CertificateDer::from(b"leaf-cert".to_vec()); + let intermediate = rustls::pki_types::CertificateDer::from(b"intermediate-cert".to_vec()); + let name = rustls::pki_types::ServerName::try_from("example.com".to_string()).unwrap(); + + let result = verifier.verify_server_cert( + &leaf, + &[intermediate], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)), + ); + + // Accepts unconditionally — the TOFU gate happens after the handshake. + assert!(result.is_ok()); + assert_eq!( + captured.lock().unwrap().as_deref(), + Some(fingerprint_hex(b"leaf-cert").as_str()) + ); + } + // ── HostScopedVerifier ────────────────────────────────────────────────── /// Stub for the non-pinned-host verifier: records nothing, just returns a diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs index 8d5b650e..a0f40394 100644 --- a/Client/tauri-client/src-tauri/src/update_commands.rs +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -220,4 +220,39 @@ mod tests { "https://chat.example.com:8443/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/0.0.0" ); } + + #[test] + fn validate_server_url_rejects_unsafe_urls() { + // build_updater() calls this first, so it is the only guard before the + // updater downloads and runs an installer from this host. + let scheme = "server_url must use https:// scheme"; + let userinfo = "server_url must not contain userinfo"; + for (url, want_err) in [ + ("http://chat.example.com", scheme), + ("ftp://chat.example.com", scheme), + ("chat.example.com", scheme), + // Case-sensitive on purpose: anything not literally https:// is out. + ("HTTPS://chat.example.com", scheme), + ("https://evil@chat.example.com", userinfo), + ("https://user:pass@chat.example.com", userinfo), + ("https://:pass@chat.example.com", userinfo), + ] { + assert_eq!( + validate_server_url(url), + Err(want_err.to_string()), + "expected {url} to be rejected" + ); + } + } + + #[test] + fn validate_server_url_accepts_plain_https() { + for url in [ + "https://chat.example.com", + "https://chat.example.com/", + "https://chat.example.com:8443/", + ] { + assert_eq!(validate_server_url(url), Ok(()), "expected {url} to pass"); + } + } } diff --git a/Client/tauri-client/src/lib/rate-limiter.ts b/Client/tauri-client/src/lib/rate-limiter.ts index 47158d1e..425e6ca8 100644 --- a/Client/tauri-client/src/lib/rate-limiter.ts +++ b/Client/tauri-client/src/lib/rate-limiter.ts @@ -159,9 +159,16 @@ export function createReactionLimiter(): RateLimiter { return createRateLimiter(5, 1_000); } -/** Voice signaling: 20 per second. */ +/** + * Voice mute/deafen toggle: 2 per second — matches the server's per-message + * budget for voice_mute and voice_deafen (Server/ws/voice_broadcast.go + * voiceMuteRateLimit/voiceDeafenRateLimit; docs/protocol.md). Gates + * onMuteToggle/onDeafenToggle (VoiceCallbacks.ts), which apply optimistic + * local state before the send — a looser client cap would let an over-budget + * toggle apply locally before the server refuses it. + */ export function createVoiceLimiter(): RateLimiter { - return createRateLimiter(20, 1_000); + return createRateLimiter(2, 1_000); } /** Voice camera / screenshare toggle: 2 per second. */ diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index c90fb4eb..81b01658 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -118,13 +118,19 @@ function uuid(): string { } /** Normalize a host for comparison against the Rust proxies' cert-tofu event - * host, mirroring `tofu::cert_store_key`'s trailing-":443" strip and - * lowercasing (src-tauri/src/tofu.rs). Profile/config hosts are stored - * verbatim (e.g. "Example.COM:443"), but the proxies always emit the - * normalized (stripped, lowercased) form, so an un-normalized comparison - * here would silently miss the match. */ + * host, mirroring `tofu::cert_store_key`'s trailing-":443" strip, portless + * bracketed-IPv6 unwrap and lowercasing (src-tauri/src/tofu.rs). Profile/ + * config hosts are stored verbatim (e.g. "Example.COM:443", or the + * bracketed "[2001:db8::1]" that hostValidation.ts accepts), but the + * proxies always emit the normalized form, so an un-normalized comparison + * here would silently miss the match. Order matters and matches the Rust: + * ":443" comes off first, so "[::1]:443" unwraps too, while a non-default + * port keeps its brackets as its own distinct key (OC-0163). */ export function normalizeHostForCertCompare(host: string): string { - return host.replace(/:443$/, "").toLowerCase(); + return host + .replace(/:443$/, "") + .replace(/^\[(.*)\]$/, "$1") + .toLowerCase(); } /** diff --git a/Client/tauri-client/tests/browser/smoke.test.ts b/Client/tauri-client/tests/browser/smoke.test.ts index 2e11b548..bb5d7274 100644 --- a/Client/tauri-client/tests/browser/smoke.test.ts +++ b/Client/tauri-client/tests/browser/smoke.test.ts @@ -1,19 +1,66 @@ -import { expect, test } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Track } from "livekit-client"; +import { createRNNoiseProcessor } from "../../src/lib/noise-suppression"; -test("browser environment is available", () => { - expect(typeof window).toBe("object"); - expect(typeof document).toBe("object"); - expect(typeof document.createElement).toBe("function"); +// Real-browser sanity checks: a real DOM is available (jsdom can fake this, +// but this suite runs in an actual Chromium instance via the vitest +// playwright provider). +describe("browser environment", () => { + it("provides real DOM globals", () => { + expect(typeof window).toBe("object"); + expect(typeof document).toBe("object"); + expect(typeof document.createElement).toBe("function"); + }); + + it("real DOM APIs work", () => { + const div = document.createElement("div"); + div.innerHTML = "hello"; + document.body.appendChild(div); + + const span = document.querySelector("span"); + expect(span).not.toBeNull(); + expect(span!.textContent).toBe("hello"); + + div.remove(); + }); }); -test("real DOM APIs work", () => { - const div = document.createElement("div"); - div.innerHTML = "hello"; - document.body.appendChild(div); +// src/lib/noise-suppression.ts needs a real AudioContext/AudioWorklet/WASM +// runtime that jsdom cannot provide (vitest.config.ts excludes it from +// coverage on that basis) — so it has to be exercised here, not in +// tests/unit. Every tests/unit reference to it is a vi.mock(). +describe("noise-suppression (real AudioContext)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); - const span = document.querySelector("span"); - expect(span).not.toBeNull(); - expect(span!.textContent).toBe("hello"); + it("falls back to a ScriptProcessorNode pipeline when AudioWorklet is unsupported", async () => { + const originalAudioWorkletNode = window.AudioWorkletNode; + // @ts-expect-error -- deleting a required DOM global to force the + // module's supportsAudioWorklet() feature check to fail + delete window.AudioWorkletNode; - div.remove(); + const audioContext = new AudioContext(); + const scriptProcessorSpy = vi.spyOn(audioContext, "createScriptProcessor"); + const inputTrack = audioContext.createMediaStreamDestination().stream.getAudioTracks()[0]!; + + try { + const processor = createRNNoiseProcessor(); + await processor.init({ + kind: Track.Kind.Audio, + track: inputTrack, + audioContext, + }); + + // The fallback pipeline is the only thing that calls createScriptProcessor. + expect(scriptProcessorSpy).toHaveBeenCalledTimes(1); + expect(processor.processedTrack).toBeInstanceOf(MediaStreamTrack); + expect(processor.processedTrack!.kind).toBe("audio"); + + await processor.destroy(); + } finally { + window.AudioWorkletNode = originalAudioWorkletNode; + await audioContext.close(); + } + }); }); diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts index b8bb766f..7516a5fc 100644 --- a/Client/tauri-client/tests/unit/auth.store.test.ts +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -18,6 +18,7 @@ import { setMessages, isChannelLoaded, getChannelMessages } from "../../src/stor import { channelsStore, setChannels } from "../../src/stores/channels.store"; import type { ReadyChannel } from "../../src/lib/types"; import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate"; +import { addLogListener, type LogEntry } from "@lib/logger"; import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types"; // Mock the lazily-imported voice SDK module so we can assert clearAuth() only @@ -273,6 +274,47 @@ describe("auth store", () => { await flushMicrotasks(); expect(leaveVoice).toHaveBeenCalledWith(false); }); + + // Boundary: a channel id can outlive the status settling back to idle + // (e.g. a leave that updated voiceStatus but hasn't cleared + // currentChannelId yet). clearAuth's guard is an AND of both conditions, + // not just "was a channel ever joined" — this pins that a set channel id + // alone must NOT trigger another leaveVoice call once already idle. + it("does NOT load livekitSession when the channel id is set but status is already idle", async () => { + joinVoiceChannel(7); + setVoiceStatus("idle"); + clearAuth(); + await flushMicrotasks(); + expect(leaveVoice).not.toHaveBeenCalled(); + }); + + // Boundary: the inverse — a non-idle status alone (no channel id) must + // also NOT trigger leaveVoice. Together with the case above, this pins + // that clearAuth requires BOTH currentChannelId set AND status !== idle, + // not either one alone. + it("does NOT load livekitSession when status is non-idle but no channel id is set", async () => { + setVoiceStatus("reconnecting"); + clearAuth(); + await flushMicrotasks(); + expect(leaveVoice).not.toHaveBeenCalled(); + }); + + it("logs a warning tagged with this module's component name when leaveVoice rejects", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const entries: LogEntry[] = []; + const unsub = addLogListener((e) => entries.push(e)); + vi.mocked(leaveVoice).mockRejectedValueOnce(new Error("boom")); + + joinVoiceChannel(7); + setVoiceStatus("connected"); + clearAuth(); + await flushMicrotasks(); + unsub(); + + const warnEntry = entries.find((e) => e.level === "warn"); + expect(warnEntry?.component).toBe("auth.store"); + expect(warnEntry?.message).toBe("Failed to leave voice session during clearAuth"); + }); }); // clearAuth's logoutWasInVoice snapshot — main.ts's isAuthenticated diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 4811e8d4..c5b91e32 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -2132,6 +2132,48 @@ describe("ChannelSidebar voice identity badge", () => { expect(document.body.querySelector(".modal-overlay")).toBeNull(); }); + // The user verifies the DISPLAYED fingerprint out of band, which takes human + // time. A malicious server can push a user_update during that window; if + // Trust re-read membersStore instead of using the captured key, the swapped + // key would get pinned (TOCTOU). + it("pins the displayed key, not one the server swapped in during the verification window", async () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { + id: 10, + username: "Alice", + avatar: null, + role: "member", + status: "online", + identityPublicKey: "alice-published-key-b64", + }); + return { ...prev, members }; + }); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + (badgeFor(10) as HTMLElement).click(); + const trustBtn = await vi.waitFor(() => { + const btn = document.body.querySelector(".modal-overlay .btn-danger") as HTMLButtonElement; + expect(btn).not.toBeNull(); + return btn; + }); + + // Server mutates the peer's key while the modal is open and the human is + // still comparing the fingerprint it displayed. + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { ...members.get(10)!, identityPublicKey: "attacker-swapped-key-b64" }); + return { ...prev, members }; + }); + + trustBtn.click(); + + expect(mockRePinPeerIdentity).toHaveBeenCalledWith(10, "alice-published-key-b64"); + expect(mockRePinPeerIdentity).not.toHaveBeenCalledWith(10, "attacker-swapped-key-b64"); + }); + it("does not re-pin when the fingerprint could not be computed (no blind accept)", async () => { addVoiceUser(VOICE_CH, 10, "Alice"); membersStore.setState((prev) => { diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts index 4e757a17..96ce1a7e 100644 --- a/Client/tauri-client/tests/unit/connect-page.test.ts +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -833,6 +833,31 @@ describe("ConnectPage", () => { page.destroy?.(); }); + it("truncates an over-long auth error to 200 chars plus ellipsis (anti-phishing cap)", async () => { + const onLogin = vi.fn().mockRejectedValue(new Error("x".repeat(500))); + const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "password123"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + const errorBanner = container.querySelector(".error-banner")!; + expect(errorBanner.textContent).toBe("x".repeat(200) + "..."); + expect(errorBanner.textContent).toHaveLength(203); + }); + + page.destroy?.(); + }); + // --- Empty username validation --- it("shows error when username is empty", async () => { diff --git a/Client/tauri-client/tests/unit/credentials.test.ts b/Client/tauri-client/tests/unit/credentials.test.ts index a021c51c..01090425 100644 --- a/Client/tauri-client/tests/unit/credentials.test.ts +++ b/Client/tauri-client/tests/unit/credentials.test.ts @@ -17,11 +17,37 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) as unknown, })); +// Captures the module's logger calls directly (message + data), the same way +// identity.test.ts does for its sibling keyring module — this is what pins +// the log strings/payloads instead of leaving them free to mutate unnoticed. +// vi.hoisted (not a plain const) because vi.mock factories are hoisted above +// the static `authStore` import, whose own module graph pulls in logger.ts +// before a plain top-level const would have run. +const { logMock, createLoggerMock } = vi.hoisted(() => { + const logMock = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { logMock, createLoggerMock: vi.fn(() => logMock) }; +}); + +vi.mock("@lib/logger", () => ({ createLogger: createLoggerMock })); + const { saveCredential, loadCredential, deleteCredential, createUserUpdateCredentialSaver } = await import("@lib/credentials"); +// saveCredential (called from createUserUpdateCredentialSaver's listener) is +// fire-and-forget: `void saveCredential(...)`. Its own body has no `await` +// until the internal dynamic import settles, so a synchronous assertion right +// after invoking the listener can't tell "the guard returned early" apart +// from "the call is merely still in flight". Flushing to a macrotask boundary +// drains every pending microtask first, so by the time this resolves any +// invoke() call that was going to happen already has. +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { invoke.mockReset().mockResolvedValue(undefined); + logMock.debug.mockReset(); + logMock.info.mockReset(); + logMock.warn.mockReset(); + logMock.error.mockReset(); }); // ── saveCredential ───────────────────────────────────────────────────────── @@ -63,6 +89,16 @@ describe("saveCredential", () => { invoke.mockRejectedValue(new Error("keychain locked")); await expect(saveCredential("h.example", "alice", "tok")).resolves.toBe(false); + // The false return is the only signal the caller sees — the host and + // underlying error have to survive somewhere, and this is it. + expect(logMock.error).toHaveBeenCalledWith("Failed to save credential", { + host: "h.example", + error: "Error: keychain locked", + }); + }); + + it("names its logger 'credentials' so these messages are filterable", () => { + expect(createLoggerMock).toHaveBeenCalledWith("credentials"); }); }); @@ -79,10 +115,11 @@ describe("createUserUpdateCredentialSaver", () => { })); }); - it("does not save when the session declined to remember the password (BUG-135)", () => { + it("does not save when the session declined to remember the password (BUG-135)", async () => { const listener = createUserUpdateCredentialSaver("h.example", false, "s3cret"); listener({ user_id: 1, username: "alice2" }); + await flushMicrotasks(); expect(invoke).not.toHaveBeenCalled(); }); @@ -105,10 +142,11 @@ describe("createUserUpdateCredentialSaver", () => { }); }); - it("ignores a user_update for someone else", () => { + it("ignores a user_update for someone else", async () => { const listener = createUserUpdateCredentialSaver("h.example", true, "s3cret"); listener({ user_id: 999, username: "bob" }); + await flushMicrotasks(); expect(invoke).not.toHaveBeenCalled(); }); @@ -159,10 +197,15 @@ describe("loadCredential", () => { expect(got).not.toHaveProperty("bogus"); }); - it("returns null when nothing is stored", async () => { + it("returns null when nothing is stored, without logging it as an error", async () => { invoke.mockResolvedValue(null); await expect(loadCredential("h.example")).resolves.toBeNull(); + // "No credential stored" is the ordinary first-run/logged-out case, not a + // failure: it must be filtered out by `result && typeof result === + // "object"` before anything tries to read a field off it, not fall + // through into the try/catch's error path. + expect(logMock.error).not.toHaveBeenCalled(); }); it.each([ @@ -182,6 +225,10 @@ describe("loadCredential", () => { invoke.mockRejectedValue(new Error("keychain locked")); await expect(loadCredential("h.example")).resolves.toBeNull(); + expect(logMock.error).toHaveBeenCalledWith("Failed to load credential", { + host: "h.example", + error: "Error: keychain locked", + }); }); }); @@ -198,6 +245,10 @@ describe("deleteCredential", () => { invoke.mockRejectedValue(new Error("no such entry")); await expect(deleteCredential("h.example")).resolves.toBe(false); + expect(logMock.error).toHaveBeenCalledWith("Failed to delete credential", { + host: "h.example", + error: "Error: no such entry", + }); }); }); @@ -233,6 +284,7 @@ describe("outside Tauri", () => { await expect(save("h.example", "alice", "tok")).resolves.toBe(false); expect(invoke).not.toHaveBeenCalled(); + expect(logMock.warn).toHaveBeenCalledWith("Tauri not available — credential not saved"); }); it("loadCredential returns null instead of throwing", async () => { diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 354e31d9..cbfab919 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -259,9 +259,25 @@ describe("WS Dispatcher", () => { ); }); - it("wires auth_error to clear auth", () => { + it("wires auth_error to a full sign-out and the login-screen banner", () => { + // Start from an authenticated session — asserting "not authenticated" + // against the beforeEach's already-signed-out store would pass even if + // the handler did nothing at all. + mock.dispatch("auth_ok", { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "TestServer", + motd: "", + }); + expect(authStore.getState().isAuthenticated).toBe(true); + mock.dispatch("auth_error", { message: "Invalid token" }); - expect(authStore.getState().isAuthenticated).toBe(false); + + const state = authStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + // The refusal reason is only ever surfaced through ui.store — ConnectPage's + // banner is its single reader. + expect(uiStore.getState().transientError).toBe("Invalid token"); }); it("wires ready to channels, members, and voice stores", () => { @@ -323,6 +339,44 @@ describe("WS Dispatcher", () => { expect(vi.mocked(mockSetMuted)).toHaveBeenCalledWith(true); }); + // The same resync path with the flags absent/false: `ready` restates our + // voice state on every full resync, so reading it as a moderator action + // would silently deafen a session nobody moderated (and there is no + // server-side undo — deafen only ever exists on this client). + it("leaves local audio alone on a ready whose self voice_state has no moderator flags", async () => { + vi.mocked(mockSetDeafened).mockClear(); + vi.mocked(mockSetMuted).mockClear(); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 3, + voiceStatus: "connected", + })); + + mock.dispatch("ready", { + channels: [], + members: [{ id: 5, username: "me", avatar: null, role: "member", status: "online" }], + voice_states: [ + { + channel_id: 3, + user_id: 5, + muted: false, + deafened: false, + server_muted: false, + server_deafened: false, + }, + ], + roles: [], + }); + await vi.runAllTimersAsync(); + + expect(vi.mocked(mockSetDeafened)).not.toHaveBeenCalled(); + expect(vi.mocked(mockSetMuted)).not.toHaveBeenCalled(); + }); + it("wires chat_message to messages store", () => { mock.dispatch("chat_message", { id: 100, @@ -1220,6 +1274,36 @@ describe("WS Dispatcher", () => { // counts are fresh and the user was not yet reading anything. expect(sender).not.toHaveBeenCalled(); }); + + // "Cleared by this ready" and "nothing was active" are different states. + // The presence check spans channels AND dm_channels, so an unrelated open + // DM must not keep a deleted channel alive; and once cleared, mark_read + // must not be sent for an id the server no longer recognizes. + it("does not mark_read the channel this ready just cleared", () => { + const sender = vi.fn(); + setMarkReadSender(sender); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 99 })); + + mock.dispatch("ready", { + channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }], + members: [], + voice_states: [], + roles: [], + dm_channels: [ + { + channel_id: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + last_message_id: null, + last_message: "", + last_message_at: "", + unread_count: 0, + }, + ], + }); + + expect(channelsStore.getState().activeChannelId).toBeNull(); + expect(sender).not.toHaveBeenCalled(); + }); }); describe("ready reconciles the DM channelsStore mirror", () => { @@ -1294,6 +1378,36 @@ describe("WS Dispatcher", () => { expect(ch?.mentionCount).toBe(0); }); + // The two counts drift independently: a mention resolved on another + // device leaves the unread count exactly where it already was, so a + // reconciliation keyed on the unread count alone would keep rendering a + // mention badge that no longer exists. + it("restates a mirror row whose mention count alone drifted", () => { + seedDmMirrorRow(2, 4); + + mock.dispatch("ready", { + channels: [], + members: [], + voice_states: [], + roles: [], + dm_channels: [ + { + channel_id: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + last_message_id: null, + last_message: "", + last_message_at: "", + unread_count: 2, + mention_count: 1, + }, + ], + }); + + const ch = channelsStore.getState().channels.get(50); + expect(ch?.mentionCount).toBe(1); + expect(ch?.unreadCount).toBe(2); + }); + it("leaves a non-dm channel row's counts alone", () => { channelsStore.setState((prev) => { const ch = new Map(prev.channels); @@ -1702,6 +1816,50 @@ describe("WS Dispatcher", () => { expect(isChannelLoaded(1)).toBe(false); expect(getHistoryLoadState(1)).toBe("error"); }); + + // Mirror of the .then guard: a rejection that lands after the user + // switched away belongs to a channel whose own mount/retry path now owns + // its load state. Flagging it errored paints an inline error + Retry on a + // channel whose load never failed, the next time the user opens it. + it("does not flag a channel load-errored when the rejection lands after a switch away", async () => { + cleanup(); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + let rejectGetMessages: ((err: Error) => void) | null = null; + const getMessages = vi.fn().mockImplementation( + () => + new Promise<{ messages: MessageResponse[]; has_more: boolean }>((_resolve, reject) => { + rejectGetMessages = reject; + }), + ); + cleanup = wireDispatcher(mock.ws, { listBlocks, getMessages }); + + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + setMessages(1, [storedMessage(10)], false); + const readyChannels = [ + { id: 1, name: "general", type: "text" as const, category: null, position: 0 }, + { id: 2, name: "other", type: "text" as const, category: null, position: 0 }, + ]; + const readyPayload = { + channels: readyChannels, + members: [], + voice_states: [], + roles: [], + dm_channels: [], + }; + + // First ready: initial connect. Second: the full-ready resync whose + // refetch for channel 1 is still in flight below. + mock.dispatch("ready", readyPayload); + mock.dispatch("ready", readyPayload); + expect(getMessages).toHaveBeenCalledWith(1, { limit: 50 }); + + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 2 })); + rejectGetMessages!(new Error("network down")); + await Promise.resolve(); + await Promise.resolve(); + + expect(getHistoryLoadState(1)).not.toBe("error"); + }); }); it("fails every pending optimistic send when the connection drops", () => { @@ -2428,6 +2586,70 @@ describe("WS Dispatcher", () => { expect(vi.mocked(mockSetMuted)).toHaveBeenCalledWith(true); }); + // server_muted and server_deafened are independent flags and each one is + // enforced separately: applying the pair whenever either is set would mute + // a user no moderator muted, and re-applying one the client has already + // honoured would fight the local toggle on every restated voice_state. + describe("moderator audio enforcement is scoped to the flags actually set", () => { + beforeEach(() => { + vi.mocked(mockSetDeafened).mockClear(); + vi.mocked(mockSetMuted).mockClear(); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + }); + + function dispatchSelfVoiceState(flags: { + server_muted?: boolean; + server_deafened?: boolean; + }): void { + mock.dispatch("voice_state", { + channel_id: 3, + user_id: 5, + username: "me", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + ...flags, + }); + } + + const cases = [ + { name: "only server_deafened is set", flags: { server_deafened: true }, deafen: 1, mute: 0 }, + { name: "only server_muted is set", flags: { server_muted: true }, deafen: 0, mute: 1 }, + { name: "neither flag is present", flags: {}, deafen: 0, mute: 0 }, + { + name: "both flags are explicitly false", + flags: { server_muted: false, server_deafened: false }, + deafen: 0, + mute: 0, + }, + ] as const; + + for (const c of cases) { + it(`calls setDeafened ${c.deafen}x and setMuted ${c.mute}x when ${c.name}`, async () => { + dispatchSelfVoiceState(c.flags); + await vi.runAllTimersAsync(); + + expect(vi.mocked(mockSetDeafened)).toHaveBeenCalledTimes(c.deafen); + expect(vi.mocked(mockSetMuted)).toHaveBeenCalledTimes(c.mute); + }); + } + + it("does not re-apply a moderator mute/deafen the client already honoured", async () => { + voiceStore.setState((prev) => ({ ...prev, localMuted: true, localDeafened: true })); + + dispatchSelfVoiceState({ server_muted: true, server_deafened: true }); + await vi.runAllTimersAsync(); + + expect(vi.mocked(mockSetDeafened)).not.toHaveBeenCalled(); + expect(vi.mocked(mockSetMuted)).not.toHaveBeenCalled(); + }); + }); + it("does not set the local moderator flags from another user's voice_state", () => { authStore.setState((prev) => ({ ...prev, @@ -2933,6 +3155,86 @@ describe("WS Dispatcher", () => { expect(updateProfile).toHaveBeenCalledWith({ identity_public_key: "k" }); }); + it("on ready publishes the signed-in user's own key, not the first member's", async () => { + cleanup(); + mockEnsurePublished.mockClear(); + const updateProfile = vi.fn().mockResolvedValue({}); + const getConfig = vi.fn(() => ({ + host: "chat.example", + token: "[redacted]", + })); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + cleanup = wireDispatcher(mock.ws, { listBlocks, updateProfile, getConfig }); + + authStore.setState((prev) => ({ + ...prev, + user: { id: 7, username: "alex", avatar: null, role: "member" }, + })); + + mock.dispatch("ready", { + channels: [], + members: [ + { + id: 3, + username: "zoe", + avatar: null, + role: "member", + status: "online", + identity_public_key: "zoe-key", + }, + { + id: 7, + username: "alex", + avatar: null, + role: "member", + status: "online", + identity_public_key: "alex-key", + }, + ], + voice_states: [], + roles: [], + }); + + await Promise.resolve(); + // Both the username and the "key the server already holds" must be ours: + // picking another member would PATCH our profile under their username, + // and a foreign current-key makes the idempotence check compare against + // the wrong value (re-publishing, or worse, skipping a needed publish). + expect(mockEnsurePublished).toHaveBeenCalledWith( + "chat.example", + "alex", + "alex-key", + expect.any(Function), + ); + }); + + it("on ready publishes no identity key when nobody is signed in", async () => { + cleanup(); + mockEnsurePublished.mockClear(); + const updateProfile = vi.fn().mockResolvedValue({}); + const getConfig = vi.fn(() => ({ + host: "chat.example", + token: "[redacted]", + })); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + cleanup = wireDispatcher(mock.ws, { listBlocks, updateProfile, getConfig }); + + // No authStore.user, so currentUserId falls back to the 0 sentinel — a + // member row carrying that id must not be mistaken for "us" and have a + // long-term identity key published against it. + authStore.setState((prev) => ({ ...prev, user: null })); + + mock.dispatch("ready", { + channels: [], + members: [{ id: 0, username: "nobody", avatar: null, role: "member", status: "online" }], + voice_states: [], + roles: [], + }); + + await Promise.resolve(); + expect(mockEnsurePublished).not.toHaveBeenCalled(); + }); + it("on ready loads the custom-emoji set from the REST list", async () => { cleanup(); const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); @@ -3247,6 +3549,10 @@ describe("WS Dispatcher", () => { expect(dm.name).toBe("Crew"); expect(dm.participants.map((p) => p.id)).toEqual([10, 11]); expect(dm.participants[0]!.displayName).toBe("Bobby"); + // DmUser.displayName is non-nullable ("" = no nickname); a participant + // the server sends without one must map to that, not to undefined — + // every render path does `displayName || username`. + expect(dm.participants[1]!.displayName).toBe(""); // The compat recipient is the first of the list, so an older render path // still shows somebody rather than nothing. expect(dm.recipient.id).toBe(10); diff --git a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts index 5a7674bd..aa324e12 100644 --- a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts +++ b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts @@ -22,6 +22,37 @@ vi.mock("@lib/logger", () => ({ })); describe("e2eeCrypto", () => { + // ── module-load WebCrypto guard ──────────────────────────────────────────── + + describe("WebCrypto availability check", () => { + async function importFresh(cryptoStub: unknown) { + vi.resetModules(); + vi.stubGlobal("crypto", cryptoStub); + try { + return await import("@lib/e2eeCrypto"); + } finally { + vi.unstubAllGlobals(); + } + } + + it("throws on import when there is no crypto global", async () => { + await expect(importFresh(undefined)).rejects.toThrow( + "E2EE requires WebCrypto (crypto.subtle). Ensure the app is served over HTTPS or a secure context.", + ); + }); + + it("throws on import when crypto exists but crypto.subtle does not", async () => { + await expect(importFresh({ getRandomValues: () => undefined })).rejects.toThrow( + /E2EE requires WebCrypto/, + ); + }); + + it("imports cleanly in a secure context", async () => { + const mod = await importFresh(globalThis.crypto); + expect(typeof mod.generateECDHKeyPair).toBe("function"); + }); + }); + // ── wrap / unwrap round-trip ─────────────────────────────────────────────── describe("wrapRoomKey / unwrapRoomKey", () => { @@ -127,15 +158,84 @@ describe("e2eeCrypto", () => { ).rejects.toThrow(); }); + it("rejects a decoded header epoch above Number.MAX_SAFE_INTEGER", async () => { + const alice = await generateECDHKeyPair(); + const bob = await generateECDHKeyPair(); + const { encryptedKey, iv } = await wrapRoomKey( + alice.privateKey, + bob.publicKey, + generateRoomKey(), + 3, + ); + const bytes = fromB64(encryptedKey); + // Overwrite the u64 epoch (bytes 1-8) with 0xFFFFFFFFFFFFFFFF, well + // above 2^53-1. The range check runs before decrypt, so the AAD + // mismatch this also creates never gets a chance to fire. + bytes.set([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], 1); + await expect(unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv)).rejects.toThrow( + "E2EE: offer epoch out of range", + ); + }); + it("rejects an epoch that is negative or not a safe integer", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); await expect( wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), -1), - ).rejects.toThrow(); + ).rejects.toThrow("E2EE: offer epoch must be a non-negative safe integer"); await expect( wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 2 ** 53), - ).rejects.toThrow(); + ).rejects.toThrow("E2EE: offer epoch must be a non-negative safe integer"); + }); + + it("accepts epoch 0 (the first epoch) at both ends", async () => { + const alice = await generateECDHKeyPair(); + const bob = await generateECDHKeyPair(); + const roomKey = generateRoomKey(); + + const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 0); + expect(Array.from(fromB64(encryptedKey).subarray(0, 9))).toEqual([1, 0, 0, 0, 0, 0, 0, 0, 0]); + + const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); + expect(unwrapped.roomKey).toEqual(roomKey); + expect(unwrapped.epoch).toBe(0); + }); + + it("accepts an epoch of exactly Number.MAX_SAFE_INTEGER", async () => { + const alice = await generateECDHKeyPair(); + const bob = await generateECDHKeyPair(); + const roomKey = generateRoomKey(); + + const { encryptedKey, iv } = await wrapRoomKey( + alice.privateKey, + bob.publicKey, + roomKey, + Number.MAX_SAFE_INTEGER, + ); + const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); + expect(unwrapped.roomKey).toEqual(roomKey); + expect(unwrapped.epoch).toBe(Number.MAX_SAFE_INTEGER); + }); + + it("rejects a blob truncated below the header length", async () => { + const alice = await generateECDHKeyPair(); + const bob = await generateECDHKeyPair(); + const { iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 3); + + // A valid version byte but only 4 of the 9 header bytes. + await expect( + unwrapRoomKey(bob.privateKey, alice.publicKey, b64(new Uint8Array([1, 0, 0, 0])), iv), + ).rejects.toThrow("E2EE: unknown wrapped-key format"); + }); + + it("rejects a non-base64 encrypted key with the base64 error", async () => { + const alice = await generateECDHKeyPair(); + const bob = await generateECDHKeyPair(); + const { iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 3); + + await expect( + unwrapRoomKey(bob.privateKey, alice.publicKey, "!!! not base64 !!!", iv), + ).rejects.toThrow("E2EE: invalid base64 input"); }); it("still unwraps a legacy blob (no header, no additional data) and reports epoch null", async () => { @@ -210,6 +310,13 @@ describe("e2eeCrypto", () => { expect(await computeRawKeyFingerprint(raw)).toBe(await computeKeyFingerprint(publicKey)); }); + it("matches a known SHA-256 vector, zero-padded and grouped", async () => { + // SHA-256 of bytes 00..09 is 1F825AA2F002…; the 0x02/0x0E/0x0D bytes only + // land in the right group if each byte is zero-padded to two hex chars. + const fp = await computeRawKeyFingerprint(new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(fp).toBe("1F82 5AA2 F002 0EF7 CF91 DFA3 0DA4 668D"); + }); + it("formats the fingerprint as 8 space-separated 4-char hex groups", async () => { const { publicKey } = await generateECDHKeyPair(); const fp = await computeKeyFingerprint(publicKey); @@ -356,6 +463,35 @@ describe("e2eeCrypto", () => { expect(ok).toBe(false); }); + it("signs exactly ANNOUNCE_DOMAIN ‖ userId ‖ ephemeralPubRaw", async () => { + const { identity, ephemeralRaw, signature } = await fixture(); + const domain = new TextEncoder().encode("owncord-voice-e2ee-announce-v1"); + const idBytes = new TextEncoder().encode(String(userId)); + const message = new Uint8Array(domain.length + idBytes.length + ephemeralRaw.length); + message.set(domain, 0); + message.set(idBytes, domain.length); + message.set(ephemeralRaw, domain.length + idBytes.length); + + const ok = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + identity.publicKey, + Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), + message, + ); + expect(ok).toBe(true); + }); + + it("returns false (not true) when subtle.verify itself throws", async () => { + const { ephemeralRaw, signature } = await fixture(); + // An ECDH public key imported with no key usages: subtle.verify rejects + // with InvalidAccessError rather than returning a boolean. + const ecdh = await generateECDHKeyPair(); + const wrongKind = await importPublicKey(await exportPublicKey(ecdh.publicKey)); + + const ok = await verifyEphemeralKeySignature(wrongKind, userId, ephemeralRaw, signature); + expect(ok).toBe(false); + }); + it("fails against a different identity key (wrong signer)", async () => { const { ephemeralRaw, signature } = await fixture(); const attacker = await generateIdentityKeyPair(); @@ -388,6 +524,9 @@ describe("e2eeCrypto", () => { true, ); + // The restored private key stays extractable, so it can be re-persisted. + expect(await exportIdentityKeyPair(restored.privateKey)).toBe(blob); + // Public key survives the round-trip identically (safety-number stability). const fpOriginal = await computeKeyFingerprint(original.publicKey); const fpRestored = await computeKeyFingerprint(restored.publicKey); diff --git a/Client/tauri-client/tests/unit/host-validation.test.ts b/Client/tauri-client/tests/unit/host-validation.test.ts new file mode 100644 index 00000000..0798fc4e --- /dev/null +++ b/Client/tauri-client/tests/unit/host-validation.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; + +import { isValidHost } from "../../src/lib/hostValidation"; + +describe("isValidHost", () => { + it("rejects a host containing '@' (identity.ts's OC-0118 scope-key premise)", () => { + // identity.ts's identityScopeKey builds `${userId}@${host}` and relies on + // isValidHost forbidding '@' in any accepted host so a scoped key can + // never collide with a legacy host-only account. If '@' were ever + // accepted here, a host literally equal to "2@chat.example" would be + // indistinguishable from userId 2 scoped to host "chat.example". + expect(isValidHost("2@chat.example")).toBe(false); + expect(isValidHost("user@evil.example:8443")).toBe(false); + }); + + it("rejects a host longer than 253 characters", () => { + const longHost = "a".repeat(254); + expect(isValidHost(longHost)).toBe(false); + // 253 is the boundary and must still be accepted (paired with a valid + // DNS label shape). + const maxHost = "a".repeat(253); + expect(isValidHost(maxHost)).toBe(true); + }); + + it("accepts a DNS name, optionally with a port", () => { + expect(isValidHost("chat.example.com")).toBe(true); + expect(isValidHost("chat.example.com:8443")).toBe(true); + }); + + it("accepts an IPv4 literal, optionally with a port", () => { + expect(isValidHost("192.168.1.1")).toBe(true); + expect(isValidHost("192.168.1.1:8443")).toBe(true); + }); + + it("accepts a bracketed IPv6 literal, optionally with a port", () => { + expect(isValidHost("[::1]")).toBe(true); + expect(isValidHost("[::1]:8443")).toBe(true); + expect(isValidHost("[2001:db8::1]")).toBe(true); + }); + + it("accepts a bare (unbracketed) IPv6 literal", () => { + expect(isValidHost("::1")).toBe(true); + expect(isValidHost("2001:db8::1")).toBe(true); + }); + + it("rejects a bracketed IPv6 literal with characters before or after the brackets", () => { + // The bracketed-IPv6 regex is anchored at both ends (^...$); without + // those anchors, a bracket pattern anywhere in the string would + // wrongly match. + expect(isValidHost("evil[::1]")).toBe(false); + expect(isValidHost("[::1]evil")).toBe(false); + }); + + it("rejects a multi-colon host whose characters are not all IPv6-valid", () => { + // More than one colon alone must not be enough to accept a host as a + // bare IPv6 literal -- every character has to be IPv6-valid too (the + // `&&`, not `||`, between the colon-count and character checks). + expect(isValidHost("not:valid:host")).toBe(false); + }); + + it("rejects a single-colon host with a non-numeric port suffix", () => { + // Exactly one colon must never satisfy the bare-IPv6 branch (which + // requires *more than* one), and it isn't a valid host:port either + // unless the suffix after the colon is numeric. + expect(isValidHost("a:b")).toBe(false); + }); + + it("rejects a multi-colon host where the IPv6-valid run is only a substring", () => { + // The bare-IPv6 character regex is anchored at both ends -- it has to + // match the whole (multi-colon) host, not just some valid-looking + // substring within or at either end of it. + expect(isValidHost("xyz:ab:cd")).toBe(false); + expect(isValidHost("ab:cd:xyz")).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/identity.test.ts b/Client/tauri-client/tests/unit/identity.test.ts index 87abceca..edfe5edb 100644 --- a/Client/tauri-client/tests/unit/identity.test.ts +++ b/Client/tauri-client/tests/unit/identity.test.ts @@ -1,12 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -const { invokeMock, logMock } = vi.hoisted(() => ({ - invokeMock: vi.fn(), - logMock: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})); +const { invokeMock, tauri, logMock, createLoggerMock } = vi.hoisted(() => { + const logMock = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { + invokeMock: vi.fn(), + // Flip `available` to false to simulate a non-Tauri environment (browser, + // plain vitest run): `@tauri-apps/api/core` resolves without a usable + // `invoke`, so `getInvoke()` yields null and every wrapper takes its + // no-op branch. Those branches are otherwise unreachable here, because + // the module mock always hands back a working `invoke`. + tauri: { available: true }, + logMock, + createLoggerMock: vi.fn(() => logMock), + }; +}); -vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); -vi.mock("@lib/logger", () => ({ createLogger: () => logMock })); +vi.mock("@tauri-apps/api/core", () => ({ + get invoke() { + return tauri.available ? invokeMock : undefined; + }, +})); +vi.mock("@lib/logger", () => ({ createLogger: createLoggerMock })); import { saveIdentityKey, @@ -51,6 +65,7 @@ beforeEach(() => { invokeMock.mockReset(); logMock.error.mockReset(); logMock.warn.mockReset(); + tauri.available = true; // The keypair memo is process-wide by design; without this, one case's // cached pair would satisfy the next case's keyring assertions. resetIdentityKeyPairCache(); @@ -84,6 +99,16 @@ describe("identity keyring wrappers", () => { invokeMock.mockRejectedValue(new Error("keyring boom")); expect(await saveIdentityKey("h", "k")).toBe(false); expect(await deleteIdentityKey("h")).toBe(false); + // The log is the only trace either failure leaves — the caller just sees + // `false` — so it has to carry the host and the underlying error. + expect(logMock.error).toHaveBeenCalledWith("Failed to save identity key", { + host: "h", + error: "Error: keyring boom", + }); + expect(logMock.error).toHaveBeenCalledWith("Failed to delete identity key", { + host: "h", + error: "Error: keyring boom", + }); }); it("loadIdentityKey rethrows (does not swallow) when the command rejects", async () => { @@ -94,6 +119,52 @@ describe("identity keyring wrappers", () => { // store failure, invalidating every peer's TOFU pin. invokeMock.mockRejectedValueOnce(new Error("keyring boom")); await expect(loadIdentityKey("h")).rejects.toThrow("keyring boom"); + expect(logMock.error).toHaveBeenCalledWith( + expect.stringMatching(/Failed to load identity key.*no key stored/), + { host: "h", error: "Error: keyring boom" }, + ); + }); + + it("names its logger 'identity' so these messages are filterable", () => { + expect(createLoggerMock).toHaveBeenCalledWith("identity"); + }); +}); + +describe("non-Tauri environment (no invoke available)", () => { + // Browser / plain-vitest runs have no Tauri IPC at all. Every wrapper has to + // no-op *distinguishably*: a save that never happened must not report + // success (loadOrGenerateIdentityKeyPair reads that boolean to decide + // whether to verify the write round-tripped), and a pin store that does not + // exist must report "no-store"/"unpinned", never "stored"/"pinned". + beforeEach(() => { + tauri.available = false; + }); + + it("saveIdentityKey reports failure — not a phantom success — and warns", async () => { + expect(await saveIdentityKey("chat.example", "blob")).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + expect(logMock.warn).toHaveBeenCalledWith(expect.stringContaining("identity key not saved")); + }); + + it("loadIdentityKey resolves null (nothing stored) instead of throwing", async () => { + await expect(loadIdentityKey("chat.example")).resolves.toBeNull(); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("deleteIdentityKey reports failure", async () => { + expect(await deleteIdentityKey("chat.example")).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("storeIdentityPin reports 'no-store', never 'stored'", async () => { + expect(await storeIdentityPin("chat.example", "42", "pubkey")).toBe("no-store"); + expect(invokeMock).not.toHaveBeenCalled(); + expect(logMock.warn).toHaveBeenCalledWith(expect.stringContaining("identity pin not stored")); + }); + + it("getIdentityPin reports 'unpinned' (TOFU first sight), never 'pinned'", async () => { + expect(await getIdentityPin("chat.example", "42")).toEqual({ status: "unpinned" }); + expect(invokeMock).not.toHaveBeenCalled(); }); }); @@ -208,6 +279,12 @@ describe("getOrCreateIdentityKeyPair", () => { const kp = await getOrCreateIdentityKeyPair("chat.example", 1); expect(kp.publicKey).toBeDefined(); expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(true); + // Silently regenerating is indistinguishable from a first login; the log + // is what tells a support reader why peers suddenly see a new identity. + expect(logMock.error).toHaveBeenCalledWith( + expect.stringContaining("Stored identity key is corrupt"), + expect.objectContaining({ host: "chat.example", userId: 1 }), + ); }); it("hands every caller the same keypair when the keyring never persists", async () => { @@ -287,6 +364,35 @@ describe("getOrCreateIdentityKeyPair", () => { }); }); + it("treats a failed read-back as 'did not persist' (does not assume success) and still returns the fresh keypair", async () => { + // Save succeeds, then the store goes unreadable. Unlike the *first* load — + // which rethrows so the caller aborts rather than overwriting an + // unreadable identity — this read is only verifying the write, and a + // freshly generated keypair is already in hand, so there is nothing to + // abort. It must still be reported as unverified: assuming the write + // stuck hides the exact failure whose only other symptom is every peer + // seeing a new identity after each restart. + let saved = false; + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") { + return saved ? Promise.reject(new Error("keychain locked")) : Promise.resolve(null); + } + if (cmd === "save_identity_key") { + saved = true; + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + + const kp = await getOrCreateIdentityKeyPair("chat.example", 1); + + expect(kp.publicKey).toBeDefined(); + expect(logMock.error).toHaveBeenCalledWith( + expect.stringMatching(/did not persist.*prompt to re-verify/), + { host: "chat.example", userId: 1 }, + ); + }); + it("aborts instead of regenerating when the keyring read fails (does not overwrite an unreadable identity)", async () => { invokeMock.mockImplementation((cmd: string) => { if (cmd === "load_identity_key") return Promise.reject(new Error("keychain locked")); @@ -426,6 +532,10 @@ describe("ensureIdentityKeyPublished (login/ready publish flow)", () => { await expect( ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile), ).resolves.toBe(false); + expect(logMock.error).toHaveBeenCalledWith("Failed to publish identity key", { + host: "chat.example", + error: "Error: network down", + }); }); it("does not mint or publish an identity key when no user is authenticated yet (never falls back to a placeholder scope)", async () => { @@ -508,6 +618,10 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => { expect(kp.publicKey).toBeDefined(); expect(store.get("1@chat.example")).toBeDefined(); expect(store.get("1@chat.example")).not.toBe("!!not-valid-jwk!!"); + expect(logMock.error).toHaveBeenCalledWith( + expect.stringContaining("Legacy identity key is corrupt"), + expect.objectContaining({ host: "chat.example" }), + ); }); it("generates fresh, with no delete attempt, when there is no legacy key either (first login)", async () => { @@ -533,5 +647,9 @@ describe("legacy identity key migration (pre-B3-3 host-only account)", () => { expect(store.get("chat.example")).toBe(legacyBlob); expect(store.has("1@chat.example")).toBe(false); + expect(logMock.error).toHaveBeenCalledWith( + expect.stringMatching(/Failed to migrate legacy identity key.*next launch can retry/), + { host: "chat.example" }, + ); }); }); diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts index 17130c73..2a8957b9 100644 --- a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -94,6 +94,7 @@ import { generateRoomKey, importPublicKey, exportPublicKey, + computeRawKeyFingerprint, } from "@lib/e2eeCrypto"; import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; import { authStore } from "@stores/auth.store"; @@ -1452,4 +1453,180 @@ describe("E2EEManager", () => { mgr.clearState(); expect(setLocalSessionFingerprint).toHaveBeenLastCalledWith(null); }); + + // ── Guards the suite reached but never pinned (mutation audit T-2026-08-19-47) ── + + it("[T-47] aborts setup when a teardown lands during the key holder's keyProvider.setKey", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + // Stall the holder's setKey — the last await before the keypair is + // published. A teardown here leaves nothing about our local state null, + // so only the session-generation check can see it. + let releaseSetKey!: () => void; + mockSetKey.mockReturnValueOnce( + new Promise((resolve) => { + releaseSetKey = resolve; + }), + ); + + const setupPromise = mgr.setupKeyExchange(true, 1); + await vi.waitFor(() => expect(mockSetKey).toHaveBeenCalled()); + + mgr.clearState(); + releaseSetKey(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Publishing the keypair here would defeat handleAnnounceInner's queue + // guard and go on to announce a dead ephemeral key over a live call. + expect((mgr as unknown as { _ecdhKeyPair: unknown })._ecdhKeyPair).toBeNull(); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0); + await expect(setupPromise).resolves.toBe(false); + }); + + it("[T-47] a superseded reconnect re-announce publishes neither its fingerprint nor a stray announce", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + ws.send.mockClear(); + vi.mocked(setLocalSessionFingerprint).mockClear(); + + // Attempt A stalls after publishing its keypair, while computing its own + // session fingerprint. + let releaseFingerprint!: (v: string) => void; + const stalledFingerprint = new Promise((resolve) => { + releaseFingerprint = resolve; + }); + vi.mocked(computeRawKeyFingerprint) + .mockReturnValueOnce(stalledFingerprint) + .mockResolvedValueOnce("FP-B"); + + const reconnectA = mgr.reannounceForReconnect(); + await vi.waitFor(() => expect(computeRawKeyFingerprint).toHaveBeenCalled()); + + // A second reconnect wins the race and publishes its own keypair. + vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({ + publicKey: { type: "pub-B" } as unknown as CryptoKey, + privateKey: { type: "priv-B" } as unknown as CryptoKey, + }); + await mgr.reannounceForReconnect(); + + releaseFingerprint("FP-A-STALE"); + await reconnectA; + + // A owns neither the published keypair nor the announce: showing the user + // A's fingerprint (or re-announcing A's dead key) misrepresents the live + // session's identity. + expect(setLocalSessionFingerprint).toHaveBeenCalledWith("FP-B"); + expect(setLocalSessionFingerprint).not.toHaveBeenCalledWith("FP-A-STALE"); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1); + }); + + it("[T-47] does not arm a reconnect-confirmation retry when there was no room key to go stale", async () => { + vi.useFakeTimers(); + try { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); // never keyed: non-holder, no room key + + await mgr.reannounceForReconnect(); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1); + + // Nothing could have gone stale — waiting for the first offer is + // setupKeyExchange's concern, so no confirmation retry belongs here. + await vi.advanceTimersByTimeAsync(6000); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("[T-47] skips the reconnect-confirmation retry once a fresh offer replaced the room key", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + await mgr.handleOffer(PEER_ID, "enc", "iv"); // stands us down — now a non-holder with a key + ws.send.mockClear(); + + vi.useFakeTimers(); + try { + await mgr.reannounceForReconnect(); + // The holder's confirming offer arrives well inside the window. + vi.mocked(unwrapRoomKey).mockResolvedValueOnce({ + roomKey: new Uint8Array(32).fill(9), + epoch: 1, + }); + await mgr.handleOffer(PEER_ID, "enc2", "iv2"); + + await vi.advanceTimersByTimeAsync(6000); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1); // no retry + } finally { + vi.useRealTimers(); + } + }); + + it("[T-47] a duplicate announce does not reset the sender's offer high-water mark", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + // Announce the exact key the export mock echoes back, so the repeat below + // takes the dedupe path rather than the key-changed path. + await mgr.handleAnnounce(PEER_ID, "bW9ja2VwaGVtZXJhbA==", "sig"); + mockSetKey.mockClear(); + vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`); + + try { + unwrapAt(5, 5); + await mgr.handleOffer(PEER_ID, "enc5", "iv5"); + expect(mockSetKey).toHaveBeenLastCalledWith("key-5"); + + // A replayed (validly signed) announce carrying the peer's CURRENT key + // must not clear the high-water mark — that would re-open the OC-0001 + // downgrade window for every superseded offer of theirs. + await mgr.handleAnnounce(PEER_ID, "bW9ja2VwaGVtZXJhbA==", "sig"); + + unwrapAt(2, 2); + await mgr.handleOffer(PEER_ID, "enc2", "iv2"); + expect(mockSetKey).toHaveBeenLastCalledWith("key-5"); + } finally { + vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64"); + } + }); + + it("[T-47] does not promote itself on a leave while a lower-id participant remains", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + // We are uid 50; uid 10 stays behind, so the election must pick them. + vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 50 } }) as never); + mockVoiceState.voiceUsers.set(1, new Map([[10, {}] as const, [50, {}] as const])); + + try { + await mgr.handleParticipantLeft(PEER_ID); + + // Self-promoting here means two holders rotating against each other: + // our offers get NOT_KEY_HOLDER'd only after we applied our own key. + expect(mgr.epoch).toBe(0); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + } finally { + vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 1 } }) as never); + } + }); + + it("[T-47] arms the periodic rotation timer for the key holder so forward secrecy actually fires", async () => { + vi.useFakeTimers(); + try { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + ws.send.mockClear(); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + + expect(mgr.epoch).toBe(2); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index 933ad3f8..cc32633c 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -477,9 +477,16 @@ describe("log persistence", () => { }); it("resolves immediately when no flush is active and no timer is pending", async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); const { clearPendingPersistedLogs } = await freshImport(); - // Should complete instantly + + // Should complete instantly, without cancelling a timer that was + // never scheduled or writing anything to disk. await clearPendingPersistedLogs(); + + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + expect(mockWriteTextFile).not.toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); }); }); diff --git a/Client/tauri-client/tests/unit/media.test.ts b/Client/tauri-client/tests/unit/media.test.ts index 6aba9f07..42f87f42 100644 --- a/Client/tauri-client/tests/unit/media.test.ts +++ b/Client/tauri-client/tests/unit/media.test.ts @@ -1171,17 +1171,26 @@ describe("media.ts", () => { }); it("cleans up document-level listeners on close", () => { + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + openImageLightbox("https://example.com/cleanup.png", "Cleanup"); + // The document-level keydown listener is registered with the lightbox's + // AbortController signal; capture it to prove the controller is aborted + // on close (rather than just re-dispatching events and asserting nothing). + const keydownCall = addEventListenerSpy.mock.calls.find( + (call) => call[0] === "keydown" && typeof call[2] === "object" && call[2] !== null, + ); + const signal = (keydownCall?.[2] as AddEventListenerOptions | undefined)?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal!.aborted).toBe(false); + const closeBtn = document.body.querySelector(".image-lightbox-close") as HTMLElement; closeBtn.click(); - // After close, key events should not error or affect anything - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - document.dispatchEvent( - new MouseEvent("mousemove", { clientX: 100, clientY: 100, bubbles: true }), - ); - document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + expect(signal!.aborted).toBe(true); + + addEventListenerSpy.mockRestore(); }); }); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index e20878e3..38b7057e 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -341,6 +341,49 @@ describe("messages store", () => { expect(msgs.map((m) => m.id)).toEqual([500, 501]); expect(msgs.every((m) => m.status === "sent")).toBe(true); }); + + it("keeps a still-pending local row when its would-be echo sits in the head that overflow-trimming drops", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "echo-in-head", + replyTo: null, + timestamp: "2026-03-15T09:59:59Z", + }); + + // Server returns newest-first; id 1 becomes the OLDEST entry once + // reversed -- exactly the entry the initial overflow trim drops before + // the pending row's echo-match ever gets to see it. It carries the + // same author+content as the still-pending row above and must NOT be + // treated as its echo (the trim has to run before the match, not after). + const responses: MessageResponse[] = []; + for (let i = 501; i >= 1; i--) { + responses.push( + makeMessageResponse({ + id: i, + content: i === 1 ? "echo-in-head" : `msg-${i}`, + user: i === 1 ? TEST_USER : TEST_USER_2, + }), + ); + } + setMessages(1, responses, false); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(500); + expect(msgs.some((m) => m.correlationId === "c1" && m.status === "pending")).toBe(true); + }); + + it("does not report hasMore at exactly the cap with no trimming and no carried overflow", () => { + const responses: MessageResponse[] = []; + for (let i = 1; i <= 500; i++) { + responses.push(makeMessageResponse({ id: i, channel_id: 1 })); + } + setMessages(1, responses, false); + + expect(getChannelMessages(1)).toHaveLength(500); + expect(hasMoreMessages(1)).toBe(false); + }); }); // 4. prependMessages prepends older messages @@ -372,6 +415,14 @@ describe("messages store", () => { expect(msgs).toHaveLength(1); expect(msgs[0]!.id).toBe(5); }); + + it("does not mark the channel detached when the prepend has no actual overflow to trim", () => { + setMessages(1, [makeMessageResponse({ id: 20 })], true); + + prependMessages(1, [makeMessageResponse({ id: 10 })], false); + + expect(isWindowDetached(1)).toBe(false); + }); }); // 5. editMessage updates content and editedAt @@ -1091,6 +1142,51 @@ describe("messages store", () => { expect(hasMoreMessages(1)).toBe(false); expect(isWindowDetached(1)).toBe(true); }); + + it("does not duplicate a carried optimistic row that already fell within the kept head on overflow", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "p1", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + addOptimisticMessage({ + correlationId: "c2", + channelId: 1, + user: TEST_USER, + content: "p2", + replyTo: null, + timestamp: "2026-03-15T10:00:01Z", + }); + addOptimisticMessage({ + correlationId: "c3", + channelId: 1, + user: TEST_USER, + content: "p3", + replyTo: null, + timestamp: "2026-03-15T10:00:02Z", + }); + + // 498 fresh + 3 already-pending rows = 501: the overflow (1) is smaller + // than the trailing pending run (3), so the split falls INSIDE that + // run -- two of the three pending rows land in the kept head and only + // the third is pushed into the carried tail. + const older: MessageResponse[] = []; + for (let i = 498; i >= 1; i--) { + older.push(makeMessageResponse({ id: i, channel_id: 1 })); + } + prependMessages(1, older, false); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(501); + expect(msgs.map((m) => m.correlationId).filter((c) => c !== null)).toEqual([ + "c1", + "c2", + "c3", + ]); + }); }); // 17. editMessage when message ID doesn't match @@ -1142,6 +1238,8 @@ describe("messages store", () => { expect(msgs[0]!.status).toBe("pending"); expect(msgs[0]!.correlationId).toBe("c1"); expect(msgs[0]!.id).toBe(0); + expect(msgs[0]!.pinned).toBe(false); + expect(msgs[0]!.deleted).toBe(false); expect(messagesStore.getState().pendingSends.get("c1")).toBe(1); }); @@ -1223,6 +1321,62 @@ describe("messages store", () => { expect(getChannelMessages(1)).toHaveLength(0); }); + it("removeOptimistic drops only the targeted row when multiple sends are still in flight", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "first", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + addOptimisticMessage({ + correlationId: "c2", + channelId: 1, + user: TEST_USER, + content: "second", + replyTo: null, + timestamp: "2026-03-15T10:00:01Z", + }); + + // Both are still tracked in pendingSends (neither failed), so this + // exercises the direct channelId-lookup branch, not the fallback scan. + removeOptimistic("c1"); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(1); + expect(msgs[0]!.correlationId).toBe("c2"); + }); + + it("removeOptimistic's fallback scan finds an already-failed row in a later channel, leaving an earlier unrelated channel untouched", () => { + // Channel 1 is inserted first and holds only an unrelated sent message, + // so it is visited first by the fallback scan's Map iteration order. + addMessage(makeChatPayload({ id: 60, channel_id: 1, content: "unrelated" })); + + // Channel 2 (inserted second) holds a sent row plus the failed + // optimistic row we're targeting -- a mixed list, so `.some` and + // `.every` disagree on whether it contains the correlation id. + addMessage(makeChatPayload({ id: 61, channel_id: 2, content: "seed" })); + addOptimisticMessage({ + correlationId: "c1", + channelId: 2, + user: TEST_USER, + content: "refused", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + markSendFailed("c1", "SLOW_MODE"); + // markSendFailed already dropped "c1" from pendingSends, so + // removeOptimistic must use the fallback scan below. + expect(messagesStore.getState().pendingSends.has("c1")).toBe(false); + + removeOptimistic("c1"); + + expect(getChannelMessages(1)).toHaveLength(1); + expect(getChannelMessages(1)[0]!.id).toBe(60); + expect(getChannelMessages(2).map((m) => m.correlationId)).toEqual([null]); + }); + it("addMessage is idempotent by real id (replay-safe)", () => { addMessage(makeChatPayload({ id: 700, content: "once" })); addMessage(makeChatPayload({ id: 700, content: "once" })); @@ -1312,6 +1466,74 @@ describe("messages store", () => { expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("failed"); }); + it("does not reconcile a pending row against a different author's identical-content broadcast", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + // Different author, identical content -- must not be treated as this + // pending row's echo (isUnreconciledEcho requires the same user id). + addMessage(makeChatPayload({ id: 900, user: TEST_USER_2, content: "hi" })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("pending"); + expect(msgs.find((m) => m.id === 900)!.user.id).toBe(TEST_USER_2.id); + }); + + it("does not re-run step-2 reconciliation against an already-confirmed row that still carries its correlationId", () => { + // confirmSend flips status to "sent" but deliberately leaves + // correlationId set (it is cleared only once the id-matched broadcast + // lands) -- a same-author/same-content broadcast under a DIFFERENT id + // must not treat this already-confirmed row as an unreconciled echo. + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + confirmSend("c1", 555, "2026-03-15T10:00:01Z"); + + addMessage(makeChatPayload({ id: 999, user: TEST_USER, content: "hi" })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs.find((m) => m.id === 555)!.correlationId).toBe("c1"); + expect(msgs.find((m) => m.id === 999)).toBeDefined(); + }); + + it("replaces only the reconciled row, leaving a sibling message in the channel untouched", () => { + addMessage( + makeChatPayload({ id: 50, channel_id: 1, user: TEST_USER_2, content: "unrelated" }), + ); + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "race", + replyTo: null, + timestamp: "2026-03-15T10:00:01Z", + }); + + // Broadcast races ahead of the ack; reconciles the pending row by + // author+content (step 2) at its own index, not the unrelated sibling + // that precedes it. + addMessage(makeChatPayload({ id: 800, user: TEST_USER, content: "race" })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs[0]!.id).toBe(50); + expect(msgs[0]!.content).toBe("unrelated"); + expect(msgs[1]!.id).toBe(800); + }); + it("keeps id/time order when another user's message commits while our send is still in flight", () => { // Channel loaded with [id 100]. addMessage(makeChatPayload({ id: 100, user: TEST_USER_2, content: "seed" })); @@ -1409,6 +1631,14 @@ describe("messages store", () => { invalidateLoadedMessageWindows(); expect(messagesStore.getState()).toBe(before); }); + + it("deletes the channel entry entirely when nothing survives (not just empties the array)", () => { + setMessages(1, [makeMessageResponse({ id: 10 })], false); + + invalidateLoadedMessageWindows(); + + expect(messagesStore.getState().messagesByChannel.has(1)).toBe(false); + }); }); describe("invalidateChannelMessageWindow", () => { diff --git a/Client/tauri-client/tests/unit/permissions.test.ts b/Client/tauri-client/tests/unit/permissions.test.ts index 56d59421..b102986e 100644 --- a/Client/tauri-client/tests/unit/permissions.test.ts +++ b/Client/tauri-client/tests/unit/permissions.test.ts @@ -254,6 +254,31 @@ describe("permissionsForRole", () => { setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); expect(currentUserPermissions()).toBe(MODERATOR_PERMS); }); + + it("currentUserPermissions denies without throwing when nobody is signed in or the role field is a malformed null", () => { + setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); + + // No signed-in user at all: authStore.getState().user?.role is undefined. + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + expect(currentUserPermissions()).toBe(0); + + // Server payload with a malformed null role (bypasses the `string` type at + // runtime, same as untrusted JSON would): must still deny, not throw. + authStore.setState(() => ({ + token: "tok", + user: { id: 1, username: "A", avatar: null, role: null as unknown as string }, + serverName: "T", + motd: null, + isAuthenticated: true, + })); + expect(currentUserPermissions()).toBe(0); + }); }); describe("roleHasPermission", () => { @@ -376,4 +401,28 @@ describe("canManageChannels / canViewAuditLog", () => { expect(canViewAuditLog()).toBe(false); expect(canManageChannels()).toBe(true); }); + + it("falls back to the exact empty string (not merely 'no match') when nobody is signed in", () => { + // Pins the literal `?? ""` fallback: a role list that happens to include + // an entry literally named "" must be reachable through the + // no-signed-in-user path, proving the fallback really is "" and not some + // other placeholder value. + setRoles([ + { + id: 20, + name: "", + color: null, + permissions: Permission.MANAGE_CHANNELS | Permission.VIEW_AUDIT_LOG, + }, + ]); + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + expect(canManageChannels()).toBe(true); + expect(canViewAuditLog()).toBe(true); + }); }); diff --git a/Client/tauri-client/tests/unit/rate-limiter.test.ts b/Client/tauri-client/tests/unit/rate-limiter.test.ts index 05c03f31..09d6a910 100644 --- a/Client/tauri-client/tests/unit/rate-limiter.test.ts +++ b/Client/tauri-client/tests/unit/rate-limiter.test.ts @@ -36,6 +36,10 @@ describe("RateLimiter", () => { expect(() => new RateLimiter({ maxTokens: 1, windowMs: 0 })).toThrow("windowMs must be >= 1"); }); + it("does not throw when windowMs is exactly 1 (boundary)", () => { + expect(() => new RateLimiter({ maxTokens: 1, windowMs: 1 })).not.toThrow(); + }); + // -- tryConsume ----------------------------------------------------------- it("allows requests under the limit", () => { @@ -162,6 +166,17 @@ describe("RateLimiter", () => { expect(limiter.getRemainingMs()).toBeGreaterThan(0); }); + + it("getRemainingMs returns 0 when under limit despite prior activity in the window", () => { + // maxTokens=3 with only 1 consumed: timestamps.length (1) < maxTokens (3) + // is true, so the under-limit guard must return 0 immediately rather than + // falling through to the oldest-timestamp math below it (which would + // wrongly report a positive wait here). + const limiter = createRateLimiter(3, 1_000); + limiter.tryConsume("a"); + + expect(limiter.getRemainingMs("a")).toBe(0); + }); }); // --------------------------------------------------------------------------- @@ -238,11 +253,16 @@ describe("Pre-configured limiters", () => { expect(limiter.tryConsume()).toBe(true); }); - it("createVoiceLimiter: 20 per 1s", () => { + // createVoiceLimiter gates onMuteToggle/onDeafenToggle (VoiceCallbacks.ts), + // which send voice_mute / voice_deafen. The server caps each of those at + // 2/sec (Server/ws/voice_broadcast.go voiceMuteRateLimit/voiceDeafenRateLimit, + // docs/protocol.md). The client limit must not exceed that budget, or an + // over-budget toggle applies its optimistic local state before the server + // refuses the send. + it("createVoiceLimiter: 2 per 1s (matches the server's voice_mute/voice_deafen budget)", () => { const limiter = createVoiceLimiter(); - for (let i = 0; i < 20; i++) { - expect(limiter.tryConsume()).toBe(true); - } + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(true); expect(limiter.tryConsume()).toBe(false); vi.advanceTimersByTime(1_001); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index f6d0fbdf..48011650 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -549,7 +549,12 @@ describe("renderers", () => { }); it("formats full date correctly for bare SQLite timestamp", () => { - const result = formatFullDate("2026-03-19 00:00:00"); + // Midday UTC, not midnight: formatFullDate renders in the local zone, and + // a UTC-midnight fixture rolls back to the 18th in any zone west of UTC + // (e.g. America/New_York). Noon UTC stays on the 19th for every zone + // from UTC-11 to UTC+11, which covers every developer/CI machine zone + // (only the rare UTC+12..+14 Pacific zones roll it forward to the 20th). + const result = formatFullDate("2026-03-19 12:00:00"); expect(result).toContain("2026"); expect(result).toContain("March"); expect(result).toContain("19"); @@ -1218,10 +1223,29 @@ describe("renderers", () => { }); }); - describe("formatMessageTimestamp — DST day boundaries", () => { + // Probe once, before the suite is even registered: `process.env.TZ` + // mutations only reach Date's local-time engine on the main thread/forks + // pool. A worker-thread pool (e.g. Stryker's vitest runner) spawns an + // isolate that never observes the change, even though the assignment + // itself succeeds. Skip the whole block there instead of failing on a pool + // limitation nothing in this file can fix; `npx vitest run` (forks, the + // documented way to run this suite) always honors the pin and runs it for + // real. + const dstProbeOriginalTZ = process.env.TZ; + process.env.TZ = "America/New_York"; + const dstTZPinHonored = + new Date(2026, 0, 15).getTimezoneOffset() === 300 && + new Date(2026, 6, 15).getTimezoneOffset() === 240; + if (dstProbeOriginalTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = dstProbeOriginalTZ; + } + + describe.skipIf(!dstTZPinHonored)("formatMessageTimestamp — DST day boundaries", () => { // These cases only exist in a DST-observing zone, so pin one for the - // duration of this block. Node honors runtime TZ changes on Linux; the - // precondition assertion in each test proves the pin took effect. + // duration of this block. The precondition assertion in each test is + // cheap insurance against the pin drifting mid-run. const originalTZ = process.env.TZ; beforeEach(() => { diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index a16395c7..13483d32 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -1472,6 +1472,44 @@ describe("SidebarArea", () => { cleanup(result); }); + it("onBack falls back to an announcement channel when no text channel remains (OC-0174)", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(5, { + id: 5, + name: "announcements", + type: "announcement", + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: false, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + // No plain "text" channel exists — only an announcement one. + return { ...prev, channels: next }; + }); + + uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" })); + + const result = createSidebarArea(defaultOpts()); + container.appendChild(result.sidebarWrapper); + + const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; + const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; + lastCall.onBack(); + + expect(uiStore.getState().sidebarMode).toBe("channels"); + expect(channelsStore.getState().activeChannelId).toBe(5); + + cleanup(result); + }); + it("onBack keeps the current channel when DM mode was entered without recording channelBeforeDm (OC-0094: 'View all messages' bypass)", () => { channelsStore.setState((prev) => { const next = new Map(prev.channels); @@ -1621,6 +1659,51 @@ describe("SidebarArea", () => { cleanup(result); }); + it("onCloseDm falls back to an announcement channel when the last DM closes with no text channel (OC-0174)", () => { + addDmChannel( + makeDm({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }), + ); + + uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" })); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(5, { + id: 5, + name: "announcements", + type: "announcement", + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: false, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + // channelBeforeDm was never recorded (DM channel added directly, not + // via selectDmConversation) and no plain "text" channel exists. + return { ...prev, channels: next, activeChannelId: 100 }; + }); + + const result = createSidebarArea(defaultOpts()); + container.appendChild(result.sidebarWrapper); + + const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; + const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; + lastCall.onCloseDm(100); + + expect(uiStore.getState().sidebarMode).toBe("channels"); + expect(channelsStore.getState().activeChannelId).toBe(5); + + cleanup(result); + }); + it("onSelectConversation selects a DM in DMs mode", () => { addDmChannel( makeDm({ diff --git a/Client/tauri-client/tests/unit/user-profile-popup.test.ts b/Client/tauri-client/tests/unit/user-profile-popup.test.ts index 9a82da9a..fb469bb8 100644 --- a/Client/tauri-client/tests/unit/user-profile-popup.test.ts +++ b/Client/tauri-client/tests/unit/user-profile-popup.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createUserProfilePopup, type UserProfileData } from "@components/UserProfilePopup"; function makeUser(overrides?: Partial): UserProfileData { @@ -174,6 +174,108 @@ describe("UserProfilePopup", () => { }); }); +describe("UserProfilePopup positioning", () => { + let container: HTMLDivElement; + let originalInnerWidth: number; + let originalInnerHeight: number; + let offsetHeightDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + originalInnerWidth = window.innerWidth; + originalInnerHeight = window.innerHeight; + }); + + afterEach(() => { + container.remove(); + window.innerWidth = originalInnerWidth; + window.innerHeight = originalInnerHeight; + if (offsetHeightDescriptor) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor); + offsetHeightDescriptor = undefined; + } + }); + + function getPopupEl(): HTMLElement { + return container.querySelector('[data-testid="user-profile-popup"]') as HTMLElement; + } + + it("places the card to the right of the anchor, offset by the gap, when it fits", () => { + window.innerWidth = 800; + window.innerHeight = 600; + const popup = createUserProfilePopup({ user: makeUser(), anchorX: 100, anchorY: 100 }); + popup.mount(container); + + // 100 + 8 (gap) = 108, and 108 + 300 (width) = 408 fits inside 800 - 8, so + // no flip. jsdom never lays anything out (offsetHeight is 0), so nothing + // overflows the bottom either and top stays at the anchor. + expect(getPopupEl().style.left).toBe("108px"); + expect(getPopupEl().style.top).toBe("100px"); + + popup.destroy?.(); + }); + + it("flips the card to the left of the anchor when there is no room on the right", () => { + window.innerWidth = 1024; + window.innerHeight = 768; + const popup = createUserProfilePopup({ user: makeUser(), anchorX: 1020, anchorY: 100 }); + popup.mount(container); + + // 1020 + 8 + 300 = 1328 overflows the 1016px right bound, so it flips to + // sit left of the anchor instead: 1020 - 300 - 8 = 712. + expect(getPopupEl().style.left).toBe("712px"); + + popup.destroy?.(); + }); + + it("clamps the left edge to the viewport margin when even the flipped position runs off both edges", () => { + window.innerWidth = 200; + window.innerHeight = 600; + const popup = createUserProfilePopup({ user: makeUser(), anchorX: 50, anchorY: 100 }); + popup.mount(container); + + // The 300px-wide card can't fit on either side of a 200px-wide window, so + // the flip still overflows negative and gets clamped to the margin. + expect(getPopupEl().style.left).toBe("8px"); + + popup.destroy?.(); + }); + + it("lifts the card above the anchor so it fits when it would run off the bottom of the window", () => { + window.innerWidth = 1024; + window.innerHeight = 500; + // jsdom never lays anything out, so offsetHeight is always 0. Stub the + // popup card's measured height so the overflow branch actually triggers. + offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return this.dataset.testid === "user-profile-popup" ? 400 : 0; + }, + }); + const popup = createUserProfilePopup({ user: makeUser(), anchorX: 10, anchorY: 450 }); + popup.mount(container); + + // 450 + 400 (measured height) = 850 overflows the 492px bottom bound, so + // the card is lifted to 500 - 400 - 8 = 92. + expect(getPopupEl().style.top).toBe("92px"); + + popup.destroy?.(); + }); + + it("clamps the top edge to the viewport margin when the anchor is near the top edge", () => { + window.innerWidth = 1024; + window.innerHeight = 768; + const popup = createUserProfilePopup({ user: makeUser(), anchorX: 10, anchorY: 2 }); + popup.mount(container); + + expect(getPopupEl().style.top).toBe("8px"); + + popup.destroy?.(); + }); +}); + // ─── Phase 6: display name, about, custom status ───────────────────────────── describe("UserProfilePopup profile fields", () => { diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 635d6280..36c0ab18 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -4,6 +4,7 @@ import { resetVoiceStore, setVoiceStates, updateVoiceState, + updateVoiceUserProfile, removeVoiceUser, joinVoiceChannel, leaveVoiceChannel, @@ -18,6 +19,14 @@ import { setVoiceConfig, getChannelVoiceUsers, setVoiceStatus, + setEncryptionDegraded, + setPeerVerification, + clearPeerVerification, + clearPeerVerifications, + getPeerVerification, + setLocalSessionFingerprint, + setPttPollingLive, + isPttPollingLive, } from "../../src/stores/voice.store"; import type { ReadyVoiceState, VoiceStatePayload, VoiceLeavePayload } from "../../src/lib/types"; import { authStore } from "../../src/stores/auth.store"; @@ -134,6 +143,38 @@ describe("voice store", () => { expect(user?.screenshare).toBe(true); }); + it("defaults username to empty string when the user isn't in membersStore", () => { + // VOICE_STATE_1's user_id (1) has no matching membersStore entry in + // this test file, so the `member?.username ?? ""` fallback applies. + setVoiceStates([VOICE_STATE_1]); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.username).toBe(""); + }); + + it("defaults serverMuted/serverDeafened to false when the ready row omits them", () => { + setVoiceStates([VOICE_STATE_1]); // no server_muted/server_deafened on this fixture + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.serverMuted).toBe(false); + expect(user?.serverDeafened).toBe(false); + }); + + it("carries serverMuted/serverDeafened through from the ready row when true", () => { + setVoiceStates([{ ...VOICE_STATE_1, server_muted: true, server_deafened: true }]); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.serverMuted).toBe(true); + expect(user?.serverDeafened).toBe(true); + }); + + it("does not auto-join when there is no signed-in user, even if a row's user_id is 0", () => { + // authStore has no user in this test, so currentUserId defaults to 0 + // (authStore.getState().user?.id ?? 0). The self-state lookup loop + // must be gated on "there IS a signed-in user", not run unconditionally + // and coincidentally match a user_id: 0 row. + joinVoiceChannel(42); + setVoiceStates([{ ...VOICE_STATE_1, user_id: 0 }]); + expect(voiceStore.getState().currentChannelId).toBe(42); + }); + it("replaces existing voice states entirely", () => { setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); setVoiceStates([VOICE_STATE_3]); @@ -263,6 +304,25 @@ describe("voice store", () => { }); }); + describe("updateVoiceUserProfile", () => { + it("patches the username in every channel the user occupies", () => { + setVoiceStates([VOICE_STATE_1]); // user 1 in channel 10 + updateVoiceState({ ...FULL_VOICE_PAYLOAD, user_id: 1, channel_id: 20 }); // user 1 also in channel 20 + + updateVoiceUserProfile(1, { username: "renamed" }); + + expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.username).toBe("renamed"); + expect(voiceStore.getState().voiceUsers.get(20)?.get(1)?.username).toBe("renamed"); + }); + + it("is a no-op (same state reference) when the user is in no voice channel", () => { + setVoiceStates([VOICE_STATE_1]); + const before = voiceStore.getState(); + updateVoiceUserProfile(999, { username: "nobody" }); + expect(voiceStore.getState()).toBe(before); + }); + }); + describe("removeVoiceUser", () => { it("removes a user from a channel", () => { setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); @@ -310,6 +370,17 @@ describe("voice store", () => { leaveVoiceChannel(); expect(voiceStore.getState().localServerMuted).toBe(false); expect(voiceStore.getState().localServerDeafened).toBe(false); + + // This describe block has no shared afterEach — without this reset the + // signed-in user leaks into every later test here (they'd silently + // stop exercising the "no signed-in user" / currentUserId === 0 path). + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); }); it("joinVoiceChannel overwrites previous channel", () => { @@ -328,6 +399,49 @@ describe("voice store", () => { leaveVoiceChannel(); expect(voiceStore.getState().currentChannelId).toBeNull(); }); + + it("leaveVoiceChannel clears encryptionDegraded with the session", () => { + joinVoiceChannel(10); + voiceStore.setState((prev) => ({ ...prev, encryptionDegraded: true })); + leaveVoiceChannel(); + expect(voiceStore.getState().encryptionDegraded).toBe(false); + }); + + it("leaveVoiceChannel with no signed-in user does not touch voiceUsers, even if a row's user_id is 0", () => { + // authStore has no user, so currentUserId defaults to 0. The + // `currentUserId === 0` guard must take the early-return branch + // (which never touches voiceUsers) rather than falling through and + // deleting a "user_id: 0" row it has no business owning. + setVoiceStates([{ ...VOICE_STATE_1, user_id: 0 }]); + joinVoiceChannel(10); + leaveVoiceChannel(); + expect(voiceStore.getState().voiceUsers.get(10)?.has(0)).toBe(true); + }); + + it("leaveVoiceChannel preserves the voiceUsers map reference when the current user isn't in it", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "dave", avatar: null, role: "member" }, + })); + // Join channel 10 locally, but voiceUsers was never populated for + // user 5 (setVoiceStates/updateVoiceState were never called for it) — + // the second guard (existingChannel missing / doesn't have the user) + // must early-return without allocating a new voiceUsers Map. + joinVoiceChannel(10); + const usersBefore = voiceStore.getState().voiceUsers; + + leaveVoiceChannel(); + + expect(voiceStore.getState().voiceUsers).toBe(usersBefore); + + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); }); describe("setLocalMuted / setLocalDeafened", () => { @@ -372,6 +486,33 @@ describe("voice store", () => { expect(voiceStore.getState().localMuted).toBe(true); expect(voiceStore.getState().pttGated).toBe(false); }); + + it("is a no-op (same state reference) when the value hasn't changed", () => { + setPttGated(true); + const before = voiceStore.getState(); + setPttGated(true); + expect(voiceStore.getState()).toBe(before); + }); + }); + + describe("setEncryptionDegraded", () => { + it("sets encryptionDegraded to true", () => { + setEncryptionDegraded(true); + expect(voiceStore.getState().encryptionDegraded).toBe(true); + }); + + it("sets encryptionDegraded back to false", () => { + setEncryptionDegraded(true); + setEncryptionDegraded(false); + expect(voiceStore.getState().encryptionDegraded).toBe(false); + }); + + it("is a no-op (same state reference) when the value hasn't changed", () => { + setEncryptionDegraded(true); + const before = voiceStore.getState(); + setEncryptionDegraded(true); + expect(voiceStore.getState()).toBe(before); + }); }); describe("setLocalCamera / setLocalScreenshare", () => { @@ -434,6 +575,42 @@ describe("voice store", () => { setLocalSpeaking(true); expect(voiceStore.getState()).toBe(before); }); + + it("is a no-op with no signed-in user, even if a row's user_id is 0", () => { + // authStore has no user, so currentUserId defaults to 0. Without the + // `currentUserId === 0` early return, the function would happily + // treat a "user_id: 0" roster row as "us" and flip its speaking flag. + setVoiceStates([{ ...VOICE_STATE_1, user_id: 0 }]); + joinVoiceChannel(10); + setLocalSpeaking(true); + expect(voiceStore.getState().voiceUsers.get(10)?.get(0)?.speaking).toBe(false); + }); + + it("is a no-op (same state reference) when speaking already matches the requested value", () => { + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "me", avatar: "", role: "member" }, + serverName: "s", + motd: "", + isAuthenticated: true, + })); + setVoiceStates([VOICE_STATE_1]); + joinVoiceChannel(10); + setLocalSpeaking(true); + const before = voiceStore.getState(); + + setLocalSpeaking(true); // already true — must not allocate a new state + + expect(voiceStore.getState()).toBe(before); + + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); }); describe("getChannelVoiceUsers", () => { @@ -496,6 +673,15 @@ describe("voice store", () => { setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" }); expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(false); }); + + it("preserves the VoiceUser object reference for a user whose speaking state doesn't change", () => { + // Both users default to speaking: false (from setVoiceStates). An + // empty speakers list changes nothing, so the unchanged branch must + // reuse the existing VoiceUser object rather than cloning it. + const before = voiceStore.getState().voiceUsers.get(10)?.get(1); + setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" }); + expect(voiceStore.getState().voiceUsers.get(10)?.get(1)).toBe(before); + }); }); describe("setListenOnly", () => { @@ -586,24 +772,37 @@ describe("voice store", () => { describe("joinVoiceChannel — same channel no-op", () => { it("does not reset joinedAt when re-joining the same channel", () => { - joinVoiceChannel(42); - const firstJoinedAt = voiceStore.getState().joinedAt; - expect(firstJoinedAt).not.toBeNull(); + // Date.now() spied with distinct values per call — two real calls in + // the same millisecond would otherwise make this assertion pass by + // coincidence even if the same-channel guard were gone entirely. + const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(1000).mockReturnValueOnce(2000); + try { + joinVoiceChannel(42); + const firstJoinedAt = voiceStore.getState().joinedAt; + expect(firstJoinedAt).toBe(1000); - // Re-join same channel - joinVoiceChannel(42); - expect(voiceStore.getState().joinedAt).toBe(firstJoinedAt); + // Re-join same channel — guard must return `prev` before Date.now() + // is called again, so joinedAt stays 1000, not the mocked 2000. + joinVoiceChannel(42); + expect(voiceStore.getState().joinedAt).toBe(firstJoinedAt); + } finally { + nowSpy.mockRestore(); + } }); it("resets joinedAt when joining a different channel", () => { joinVoiceChannel(42); - const firstJoinedAt = voiceStore.getState().joinedAt; - // Small delay to ensure different timestamp joinVoiceChannel(99); expect(voiceStore.getState().joinedAt).not.toBeNull(); expect(voiceStore.getState().currentChannelId).toBe(99); }); + + it("clears encryptionDegraded on a fresh join — a new session doesn't inherit the previous one's degraded flag", () => { + voiceStore.setState((prev) => ({ ...prev, encryptionDegraded: true })); + joinVoiceChannel(42); + expect(voiceStore.getState().encryptionDegraded).toBe(false); + }); }); describe("voiceStatus", () => { @@ -626,6 +825,13 @@ describe("voice store", () => { setVoiceStatus("reconnecting"); expect(voiceStore.getState().voiceStatus).toBe("reconnecting"); }); + + it("setVoiceStatus is a no-op (same state reference) when the status hasn't changed", () => { + setVoiceStatus("securing"); + const before = voiceStore.getState(); + setVoiceStatus("securing"); + expect(voiceStore.getState()).toBe(before); + }); }); describe("leaveVoiceChannel — clears user from voiceUsers", () => { @@ -755,6 +961,47 @@ describe("voice store", () => { expect(state.voiceUsers.size).toBe(0); expect(state.voiceConfigs.size).toBe(0); }); + + it("resets localServerMuted/localServerDeafened/pttGated/encryptionDegraded to false", () => { + voiceStore.setState((prev) => ({ + ...prev, + localServerMuted: true, + localServerDeafened: true, + pttGated: true, + encryptionDegraded: true, + })); + + resetVoiceStore(); + + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(false); + expect(state.localServerDeafened).toBe(false); + expect(state.pttGated).toBe(false); + expect(state.encryptionDegraded).toBe(false); + }); + }); + + describe("setPttPollingLive / isPttPollingLive", () => { + afterEach(() => { + // Module-level flag, not covered by resetStore() — restore the + // default so later tests in this file see a clean slate. + setPttPollingLive(false); + }); + + it("defaults to false", () => { + expect(isPttPollingLive()).toBe(false); + }); + + it("setPttPollingLive(true) flips isPttPollingLive() to true", () => { + setPttPollingLive(true); + expect(isPttPollingLive()).toBe(true); + }); + + it("resetVoiceStore() does not clear it — process-wide platform capability, not per-session voice state", () => { + setPttPollingLive(true); + resetVoiceStore(); + expect(isPttPollingLive()).toBe(true); + }); }); describe("clearAuth voice cleanup", () => { @@ -776,6 +1023,121 @@ describe("voice store", () => { }); }); + describe("setPeerVerification / clearPeerVerification / clearPeerVerifications / getPeerVerification (F3 TOFU)", () => { + it("getPeerVerification returns null for an unknown peer", () => { + expect(getPeerVerification(1)).toBeNull(); + }); + + it("setPeerVerification records a peer's verification, readable via getPeerVerification", () => { + setPeerVerification({ + userId: 1, + status: "verified", + safetyNumber: "abcd-1234", + sessionFingerprint: "fp-1", + }); + expect(getPeerVerification(1)).toEqual({ + userId: 1, + status: "verified", + safetyNumber: "abcd-1234", + sessionFingerprint: "fp-1", + }); + }); + + it("setPeerVerification overwrites a previous verification for the same peer", () => { + setPeerVerification({ + userId: 1, + status: "unverified", + safetyNumber: null, + sessionFingerprint: "fp-1", + }); + setPeerVerification({ + userId: 1, + status: "mismatch", + safetyNumber: null, + sessionFingerprint: "fp-2", + }); + expect(getPeerVerification(1)?.status).toBe("mismatch"); + expect(getPeerVerification(1)?.sessionFingerprint).toBe("fp-2"); + }); + + it("clearPeerVerification removes a single peer's verification", () => { + setPeerVerification({ + userId: 1, + status: "verified", + safetyNumber: "x", + sessionFingerprint: "y", + }); + setPeerVerification({ + userId: 2, + status: "verified", + safetyNumber: "x", + sessionFingerprint: "y", + }); + + clearPeerVerification(1); + + expect(getPeerVerification(1)).toBeNull(); + expect(getPeerVerification(2)).not.toBeNull(); + }); + + it("clearPeerVerification is a no-op (same state reference) for a peer with no verification", () => { + setPeerVerification({ + userId: 2, + status: "verified", + safetyNumber: "x", + sessionFingerprint: "y", + }); + const before = voiceStore.getState(); + clearPeerVerification(999); + expect(voiceStore.getState()).toBe(before); + }); + + it("clearPeerVerifications drops every peer's verification", () => { + setPeerVerification({ + userId: 1, + status: "verified", + safetyNumber: "x", + sessionFingerprint: "y", + }); + setPeerVerification({ + userId: 2, + status: "unverified", + safetyNumber: null, + sessionFingerprint: "z", + }); + + clearPeerVerifications(); + + expect(voiceStore.getState().peerVerifications?.size).toBe(0); + }); + + it("clearPeerVerifications is a no-op (same state reference) when already empty", () => { + const before = voiceStore.getState(); + clearPeerVerifications(); + expect(voiceStore.getState()).toBe(before); + }); + }); + + describe("setLocalSessionFingerprint", () => { + it("publishes the local session fingerprint", () => { + setLocalSessionFingerprint("fp-local"); + expect(voiceStore.getState().localSessionFingerprint).toBe("fp-local"); + }); + + it("clears the fingerprint with null", () => { + setLocalSessionFingerprint("fp-local"); + setLocalSessionFingerprint(null); + expect(voiceStore.getState().localSessionFingerprint).toBeNull(); + }); + + it("is a no-op (same state reference) when the value hasn't changed", () => { + setLocalSessionFingerprint("fp-local"); + const before = voiceStore.getState(); + setLocalSessionFingerprint("fp-local"); + expect(voiceStore.getState()).toBe(before); + }); + }); + describe("subscribe", () => { it("notifies on state changes", () => { const listener = vi.fn(); @@ -794,4 +1156,29 @@ describe("voice store", () => { expect(listener).not.toHaveBeenCalled(); }); }); + + // MUST stay the last describe block in this file: vi.resetModules() below + // repoints subsequent `await import(...)` calls at fresh module instances + // for the rest of the file's execution, which would desync any later + // test's dynamically-imported module from this file's statically-imported + // `voiceStore`/`authStore` (as it did for the "clearAuth voice cleanup" + // test above, which relies on that static `voiceStore` observing the + // effect of a dynamically-imported `clearAuth()`). + describe("module INITIAL_STATE (fresh import, untouched by any reset)", () => { + it("defaults every flag to false — the describe blocks above only ever observe state after the outer beforeEach's resetStore() has already overwritten it, and that local helper doesn't even set localServerMuted/localServerDeafened/pttGated/encryptionDegraded (they'd read `undefined` there, not the real default)", async () => { + vi.resetModules(); + const fresh = await import("../../src/stores/voice.store"); + const state = fresh.voiceStore.getState(); + expect(state.localMuted).toBe(false); + expect(state.localDeafened).toBe(false); + expect(state.localServerMuted).toBe(false); + expect(state.localServerDeafened).toBe(false); + expect(state.pttGated).toBe(false); + expect(state.localCamera).toBe(false); + expect(state.localScreenshare).toBe(false); + expect(state.listenOnly).toBe(false); + expect(state.encryptionDegraded).toBe(false); + expect(fresh.isPttPollingLive()).toBe(false); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/ws-cert.test.ts b/Client/tauri-client/tests/unit/ws-cert.test.ts index 78aba281..ac26f6fc 100644 --- a/Client/tauri-client/tests/unit/ws-cert.test.ts +++ b/Client/tauri-client/tests/unit/ws-cert.test.ts @@ -150,6 +150,50 @@ describe("cert mismatch blocking", () => { expect(reconnectCalls).toHaveLength(0); }); + it("blocks reconnect when a bracketed IPv6 profile host meets the Rust proxy's unbracketed event host", async () => { + // Regression for OC-0163's TS half: a profile may be saved bracketed + // ("[2001:db8::1]", a form hostValidation.ts's isValidHost accepts), and + // ws_connect embeds it verbatim in the wss:// authority — so the Rust + // side derives the pin key via tofu::cert_store_key, which unwraps a + // PORTLESS bracketed IPv6 literal and emits the bare host. Without the + // matching unwrap in normalizeHostForCertCompare, this guard never + // matches for an IPv6 server and the reconnect loop keeps re-handshaking + // the host whose certificate just changed. + client.connect({ host: "[2001:db8::1]", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Rust-normalized event host — brackets stripped by cert_store_key. + emitTauriEvent("cert-tofu", { + host: "2001:db8::1", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + expect(client.getState()).toBe("disconnected"); + + emitTauriEvent("ws-state", "closed"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnectCalls).toHaveLength(0); + }); + it("should unblock after acceptCertFingerprint", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); @@ -495,6 +539,30 @@ describe("normalizeHostForCertCompare", () => { const lastConnectHost = "Example.COM:443"; expect(evtHost === normalizeHostForCertCompare(lastConnectHost)).toBe(true); }); + + it("unwraps a portless bracketed IPv6 literal exactly as cert_store_key does", () => { + // Mirrors the Rust assertions in + // src-tauri/src/tofu.rs::cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host. + // A profile saved as "[2001:db8::1]" reaches the proxies bracketed, and + // cert_store_key unwraps it (and its default-port form) before the + // cert-tofu event is emitted; this mirror has to land on the same string + // or main.ts's onAccept/onReject host guards silently never match. + expect(normalizeHostForCertCompare("[2001:db8::1]")).toBe("2001:db8::1"); + expect(normalizeHostForCertCompare("[2001:DB8::1]:443")).toBe("2001:db8::1"); + // A non-default port keeps its brackets — a genuinely distinct pin key, + // same as the plain "host:8443" case never collapsing to "host". + expect(normalizeHostForCertCompare("[2001:db8::1]:8443")).toBe("[2001:db8::1]:8443"); + }); + + it("strips :443 only as a trailing port, never mid-string", () => { + // cert_store_key strips the DEFAULT port and nothing else. An unanchored + // strip would eat the ":443" inside any other port that starts with it + // (":4430", ":4433"), leaving main.ts's `evt.host === normalize(host)` + // guard comparing against a host string the proxies never emit — so a + // rejected certificate's teardown would silently never fire. + expect(normalizeHostForCertCompare("example.com:443")).toBe("example.com"); + expect(normalizeHostForCertCompare("example.com:4430")).toBe("example.com:4430"); + }); }); describe("cert-tofu non-mismatch statuses", () => { diff --git a/Client/tauri-client/tests/unit/ws-lifecycle.test.ts b/Client/tauri-client/tests/unit/ws-lifecycle.test.ts index db6395b9..b8ef4a4e 100644 --- a/Client/tauri-client/tests/unit/ws-lifecycle.test.ts +++ b/Client/tauri-client/tests/unit/ws-lifecycle.test.ts @@ -12,7 +12,7 @@ vi.mock("@tauri-apps/api/event", async () => ({ })); import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; -import { createWsClient } from "../../src/lib/ws"; +import { createWsClient, bracketBareIPv6Host } from "../../src/lib/ws"; import { addLogListener, type LogEntry } from "../../src/lib/logger"; describe("WebSocket Client (Tauri proxy)", () => { @@ -392,6 +392,38 @@ describe("heartbeat", () => { ); expect(pingSends).toHaveLength(0); }); + + it("does not stack a second interval when auth_ok arrives twice", async () => { + // startHeartbeat() clears the previous interval before arming a new one. + // A resumed session can see two auth_ok frames (server replay, or an + // auth_ok racing a reconnect); if the old interval survived, every + // heartbeat window would fire N pings for N handshakes. + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const authOk = JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }); + emitTauriEvent("ws-message", authOk); + emitTauriEvent("ws-message", authOk); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(30_000); + + const pingSends = mockInvoke.mock.calls.filter( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), + ); + expect(pingSends).toHaveLength(1); + }); }); describe("setState deduplication", () => { @@ -423,6 +455,22 @@ describe("setState deduplication", () => { expect(connectingCount).toBe(1); }); + it("does not notify at all for a transition onto the state already held", () => { + // A fresh client is already "disconnected", and disconnect() runs + // setState("disconnected") on top of that. Only the `state !== newState` + // guard keeps that no-op silent — without it ui.store's connection banner + // re-renders (and flaps) on every redundant transition. The test above + // counts occurrences of a state that is only ever set once, so it cannot + // see the guard at all; this one drives a genuine no-op transition. + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + client.disconnect(); + + expect(states).toEqual([]); + expect(client.getState()).toBe("disconnected"); + }); + it("notifies listeners when state actually changes", async () => { const states: ConnectionState[] = []; client.onStateChange((s) => states.push(s)); @@ -927,11 +975,6 @@ describe("connect when Tauri APIs unavailable", () => { it("falls back to disconnected when ensureTauriApis fails", async () => { vi.useFakeTimers(); - // Create a fresh client that will try to load Tauri APIs fresh - // The mock is already set up to resolve, so we need to simulate unavailability - // by making tauriInvoke null after ensureTauriApis - const origInvoke = mockInvoke; - // Temporarily clear the mock module to simulate Tauri not available // We test this indirectly: if ws_connect is never called but state // goes back to disconnected, the guard worked @@ -1197,3 +1240,36 @@ describe("listener registry mechanics (on/off/dispatch)", () => { expect(received).toEqual(["hello", "fourth:hello"]); }); }); + +describe("bracketBareIPv6Host (OC-0163)", () => { + // The URL-building half of OC-0163 (the cert-compare half is + // normalizeHostForCertCompare, covered in ws-cert.test.ts). Only a BARE + // IPv6 literal — more than one colon and nothing outside the IPv6 character + // set — may be bracketed; every other host has to come back byte-identical + // or the `wss:///api/v1/ws` authority it is spliced into stops + // resolving. The connect() tests above cover the three headline cases; this + // table pins the edges each half of the guard exists for. + it.each([ + ["2001:db8::1", "[2001:db8::1]"], + ["::1", "[::1]"], + // Already bracketed (with or without a port) — never double-bracket. + ["[2001:db8::1]", "[2001:db8::1]"], + ["[2001:db8::1]:8443", "[2001:db8::1]:8443"], + // No colon at all: DNS name or IPv4 literal. + ["example.com", "example.com"], + ["192.168.1.1", "192.168.1.1"], + // Exactly one colon is the host:port separator, never an IPv6 literal — + // this is why the colon count is `> 1` and not `>= 1`. The IPv4 case also + // passes the character-set test, so the colon count is the only thing + // keeping it unbracketed. + ["example.com:8443", "example.com:8443"], + ["192.168.1.1:8443", "192.168.1.1:8443"], + // Character-set test is anchored at both ends: a hex-looking prefix with + // a non-hex tail (zone id) and a non-hex head with a hex-looking tail are + // both rejected — neither is a literal that can sit in a URL authority. + ["fe80::1%eth0", "fe80::1%eth0"], + ["my-server:1:2", "my-server:1:2"], + ])("leaves %s as %s", (input, expected) => { + expect(bracketBareIPv6Host(input)).toBe(expected); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws-messaging.test.ts b/Client/tauri-client/tests/unit/ws-messaging.test.ts index f8416458..12f272bc 100644 --- a/Client/tauri-client/tests/unit/ws-messaging.test.ts +++ b/Client/tauri-client/tests/unit/ws-messaging.test.ts @@ -37,10 +37,19 @@ describe("message handling edge cases", () => { emitTauriEvent("ws-state", "open"); const messages: unknown[] = []; - // pong has no payload listeners, but we verify no crash client.on("chat_message", (p) => messages.push(p)); - emitTauriEvent("ws-message", JSON.stringify({ type: "pong" })); + // "pong" is not part of the ServerMessage union — the transport eats it + // before dispatch — so the registry has to be reached through a cast to + // prove nothing arrives. The frame carries a payload on purpose: a + // payload-less pong is also dropped by the "missing type or payload" + // guard further down, which would hide a broken early return. + const onPong = vi.fn(); + (client.on as unknown as (t: string, l: () => void) => () => void)("pong", onPong); + + emitTauriEvent("ws-message", JSON.stringify({ type: "pong", payload: {} })); + + expect(onPong).not.toHaveBeenCalled(); expect(messages).toHaveLength(0); }); @@ -329,11 +338,13 @@ describe("handleMessage size boundary", () => { msg.payload.content = "x".repeat(padding); } const exactJson = JSON.stringify(msg); - // Ensure it is exactly at limit (not over) - expect(exactJson.length).toBeLessThanOrEqual(limit); + // Exactly ON the limit, not merely under it — the guard drops only what is + // strictly OVER, so a frame one byte short would pass either way and prove + // nothing about the boundary. + expect(exactJson.length).toBe(limit); emitTauriEvent("ws-message", exactJson); - expect(messages.length).toBeGreaterThanOrEqual(0); // should not crash + expect(messages).toHaveLength(1); }); it("drops message one byte over size limit", async () => { diff --git a/Client/tauri-client/vitest.config.ts b/Client/tauri-client/vitest.config.ts index 33cc4a22..86cec9e9 100644 --- a/Client/tauri-client/vitest.config.ts +++ b/Client/tauri-client/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import { resolve } from "path"; export default defineConfig({ @@ -18,6 +18,10 @@ export default defineConfig({ // Both the `tests/**/*.test.ts` suite and component-local // `src/**/*.test.ts` files are picked up. include: ["tests/**/*.test.ts", "src/**/*.test.ts"], + // tests/browser/ runs real browser APIs (AudioContext, WASM) under + // vitest.config.browser.ts (`npm run test:browser`); it cannot pass in + // jsdom and is not part of this suite. + exclude: [...configDefaults.exclude, "tests/browser/**"], coverage: { provider: "v8", include: ["src/**/*.ts"], diff --git a/Server/admin/channels_archive_voice_test.go b/Server/admin/channels_archive_voice_test.go index c4ac67a7..e8372481 100644 --- a/Server/admin/channels_archive_voice_test.go +++ b/Server/admin/channels_archive_voice_test.go @@ -127,3 +127,41 @@ func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testin t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes)) } } + +// OC-0158, create side: handleCreateChannel commits AdminCreateChannel and +// only afterwards re-reads the row to broadcast it. A caller cancellation +// landing in that window (tab close, network blip) failed the re-read and +// 500ed the request, leaving a durably created channel no connected client +// was ever told about — the same shape already fixed in the PATCH and DELETE +// siblings. The hook fires synchronously right after the commit so the window +// is hit deterministically instead of by wall-clock timing. +func TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + ctx, cancel := context.WithCancel(context.Background()) + restore := admin.SetCreateChannelPostCommitHook(func() { + cancel() + }) + defer restore() + + body, _ := json.Marshal(map[string]any{"name": "create-cancel-race", "type": "text"}) + req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (create must survive a caller cancellation that arrives after the row already committed); body: %s", w.Code, w.Body.String()) + } + if len(hub.channelCreates) != 1 { + t.Fatalf("BroadcastChannelCreate called %d times, want 1 — the row committed, so connected clients must be told", len(hub.channelCreates)) + } + if hub.channelCreates[0].Name != "create-cancel-race" { + t.Errorf("broadcast channel name = %q, want create-cancel-race", hub.channelCreates[0].Name) + } +} diff --git a/Server/admin/export_test.go b/Server/admin/export_test.go index 19eddf43..b69f41fd 100644 --- a/Server/admin/export_test.go +++ b/Server/admin/export_test.go @@ -59,6 +59,16 @@ func SetPatchChannelPostCommitHook(h func()) (restore func()) { return func() { patchChannelPostCommitHook = prev } } +// SetCreateChannelPostCommitHook installs h to run synchronously right after +// handleCreateChannel's AdminCreateChannel commit, before the post-commit +// re-read and hub fan-out — the create-side twin of +// SetPatchChannelPostCommitHook (OC-0158). +func SetCreateChannelPostCommitHook(h func()) (restore func()) { + prev := createChannelPostCommitHook + createChannelPostCommitHook = h + return func() { createChannelPostCommitHook = prev } +} + // StubCopyBackup swaps the restore path's file-copy hook so tests can inject // mid-copy failures that pass the pre-copy integrity gate. CopyBackupForTest // is the real implementation, for stubs that only want to fail once. diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index abe29f0e..8f03563e 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -93,6 +93,13 @@ type createChannelRequest struct { Position int `json:"position"` } +// createChannelPostCommitHook, when non-nil, runs synchronously right after +// handleCreateChannel's AdminCreateChannel commit, before the post-commit +// re-read and hub fan-out — the create-side twin of +// patchChannelPostCommitHook, so tests can land a caller cancellation in that +// exact window (OC-0158) instead of relying on wall-clock timing. +var createChannelPostCommitHook func() + func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req createChannelRequest @@ -120,14 +127,28 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - ch, err := database.GetChannel(r.Context(), id) + // From here on the row has already committed. If the admin's browser + // goes away in this window (tab close, navigation, network blip), + // r.Context() cancels, and a GetChannel re-read that still used it + // would fail with context.Canceled — 500ing while leaving a durably + // created channel unbroadcast, so no connected client learns about it + // until it reconnects (OC-0158). Run the rest of the handler on an + // uncancellable tail, matching handlePatchChannel and + // handleDeleteChannel. + tail := context.WithoutCancel(r.Context()) + + if createChannelPostCommitHook != nil { + createChannelPostCommitHook() + } + + ch, err := database.GetChannel(tail, id) if err != nil || ch == nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel") return } actor := actorFromContext(r) slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type) - db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_create", "channel", id, + db.WriteAudit(tail, database, actor, "channel_create", "channel", id, fmt.Sprintf("created #%s (%s)", req.Name, req.Type)) if hub != nil { hub.BroadcastChannelCreate(ch) diff --git a/Server/api/clientip_test.go b/Server/api/clientip_test.go index 9c4d8702..3f9e777a 100644 --- a/Server/api/clientip_test.go +++ b/Server/api/clientip_test.go @@ -190,6 +190,39 @@ func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) { } } +// TestClientIP_XForwardedFor_SkipsMalformedEntries pins the skip guard in the +// right-to-left walk: a garbage or empty XFF entry must be stepped over, never +// used as a rate-limit/lockout key. The garbage sits to the RIGHT of the real +// client so the walk actually reaches it. Dropping the guard would return +// "not-an-ip" / "garbage" as the key. +func TestClientIP_XForwardedFor_SkipsMalformedEntries(t *testing.T) { + trusted := parseCIDRList([]string{"10.0.0.0/8"}) + + tests := []struct { + name string + xff string + want string + }{ + {"garbage rightmost", "203.0.113.10, not-an-ip", "203.0.113.10"}, + {"empty entry", "203.0.113.10, , 10.0.0.1", "203.0.113.10"}, + {"garbage between hops", "203.0.113.10, ::gg::, 10.0.0.1", "203.0.113.10"}, + // Nothing parseable at all: fall back to RemoteAddr, never a garbage key. + {"all malformed falls back to RemoteAddr", "garbage, , not-an-ip", "10.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.1:9999" // the trusted proxy + req.Header.Set("X-Forwarded-For", tt.xff) + + if ip := clientIPWithProxies(req, trusted); ip != tt.want { + t.Errorf("clientIP XFF %q = %q, want %q", tt.xff, ip, tt.want) + } + }) + } +} + func TestClientIP_RemoteAddrWithoutPort(t *testing.T) { // RemoteAddr sometimes has no port (e.g. Unix sockets in tests). req := httptest.NewRequest("GET", "/", nil) diff --git a/Server/api/constants_test.go b/Server/api/constants_test.go index 3bb15ca9..44f98e73 100644 --- a/Server/api/constants_test.go +++ b/Server/api/constants_test.go @@ -1,6 +1,10 @@ package api -import "testing" +import ( + "os" + "strings" + "testing" +) // I-7: loginRateLimitPerMinute must be 5 (not 60). func TestLoginRateLimit_Value(t *testing.T) { @@ -21,3 +25,83 @@ func TestRateLimiterCleanupHorizon_CoversMaxSlowMode(t *testing.T) { rateLimiterCleanupMaxWindow, maxSlowMode) } } + +// setAuthRateScale/scaledAuthLimit gate every per-IP auth limit +// (auth_handler.go:107-136) and the per-IP login failure threshold that arms +// the lockout (auth_handler.go:514,537). The multiplier is operator-supplied +// via security.auth_rate_limit_multiplier and config validates nothing, so +// this clamp is all that stands between a typo and brute-force protection +// disappearing. +func TestSetAuthRateScale_ClampsMultiplier(t *testing.T) { + t.Cleanup(func() { setAuthRateScale(1.0) }) + + tests := []struct { + name string + mult float64 + limit int + want int + }{ + {"unset config means 1x", 0, loginRateLimitPerMinute, 5}, + {"negative means 1x", -3.5, loginRateLimitPerMinute, 5}, + {"1x leaves the limit alone", 1, registerRateLimitPerMinute, 3}, + {"above the cap clamps to 100x", 1e9, loginRateLimitPerMinute, 500}, + {"at the cap is 100x", 100, loginRateLimitPerMinute, 500}, + {"below the floor clamps to 0.1x", 1e-9, verifyTOTPRateLimitPerMinute, 1}, + {"at the floor is 0.1x", 0.1, verifyTOTPRateLimitPerMinute, 1}, + {"in range scales and rounds", 0.5, loginRateLimitPerMinute, 3}, + {"in range scales the failure threshold", 2, loginFailureThreshold, 18}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setAuthRateScale(tt.mult) + if got := scaledAuthLimit(tt.limit); got != tt.want { + t.Errorf("setAuthRateScale(%v); scaledAuthLimit(%d) = %d, want %d", + tt.mult, tt.limit, got, tt.want) + } + }) + } +} + +// A limit of 0 lets nothing through: on the login failure threshold +// (auth_handler.go:514) that locks every IP out on its first attempt. The +// smallest allowed multiplier must still leave every scaled limit usable. +func TestScaledAuthLimit_NeverBelowOne(t *testing.T) { + t.Cleanup(func() { setAuthRateScale(1.0) }) + setAuthRateScale(0.1) + + for _, n := range []int{ + 1, + registerRateLimitPerMinute, + loginRateLimitPerMinute, + verifyTOTPRateLimitPerMinute, + sensitiveEndpointRateLimitPerMinute, + loginFailureThreshold, + } { + if got := scaledAuthLimit(n); got < 1 { + t.Errorf("scaledAuthLimit(%d) = %d at the 0.1x floor, want >= 1", n, got) + } + } +} + +// The multiplier exists for shared-NAT *per-IP* limits. The per-user caps are +// the only cross-IP brute-force defence, so scaling them would hand a +// distributed attacker up to 100x the guesses (totp_handler.go:76-80). Those +// caps are only observable through a limiter key inside the handler, so this +// pins the call site instead. +func TestPerUserFailureCapsStayUnscaled(t *testing.T) { + for file, constants := range map[string][]string{ + "totp_handler.go": {"totpFailureRateLimit"}, + "auth_handler.go": {"loginUserFailureThreshold"}, + } { + src, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + for _, c := range constants { + if strings.Contains(string(src), "scaledAuthLimit("+c) { + t.Errorf("%s scales %s with the per-IP auth multiplier; per-user caps must stay unscaled", + file, c) + } + } + } +} diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 715bcbb5..11849737 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -391,6 +391,52 @@ func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { } } +// TestRequirePermission_NoRoleInContext pins the fail-closed branch: the +// server-wide authz chokepoint must deny when the request context carries no +// usable *db.Role. Every other RequirePermission test composes AuthMiddleware, +// which always installs a non-nil role, so without this the guard could be +// rewritten to `if !ok { next.ServeHTTP(w, r); return }` and stay green. +func TestRequirePermission_NoRoleInContext(t *testing.T) { + var nilRole *db.Role + + tests := []struct { + name string + ctx func(context.Context) context.Context + }{ + {"missing key", func(ctx context.Context) context.Context { return ctx }}, + {"typed nil role", func(ctx context.Context) context.Context { + return context.WithValue(ctx, api.RoleKey, nilRole) + }}, + {"wrong type", func(ctx context.Context) context.Context { + return context.WithValue(ctx, api.RoleKey, "administrator") + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + downstream := false + h := api.RequirePermission(permissions.ManageServer)( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downstream = true + ok(w, r) + }), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(tt.ctx(req.Context())) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("RequirePermission without role status = %d, want 403", rr.Code) + } + if downstream { + t.Error("RequirePermission without role ran the downstream handler") + } + }) + } +} + // ─── RateLimitMiddleware tests ──────────────────────────────────────────────── func TestRateLimitMiddleware_UnderLimit(t *testing.T) { diff --git a/Server/api/waf_test.go b/Server/api/waf_test.go index 7370a772..4e7ea0d2 100644 --- a/Server/api/waf_test.go +++ b/Server/api/waf_test.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -89,6 +90,67 @@ func TestWAFMiddleware_BlocksScannerUserAgent(t *testing.T) { } } +// The inline engine's four phase-2 request-body rules must actually block a +// body-borne attack payload, not just log it: SQLi (942100), XSS (941100), +// path traversal (930100) and command injection (932100) are all +// deny,status:403 under an always-on SecRuleEngine. Every other blocking test +// in the suite fires the phase-1 User-Agent rule on a bodyless GET, so this is +// what pins wafInspectRequestBody's interruption path. +// +// CRS mode off is deliberate: with no CRS engine attached the inline engine is +// the only thing that can block, so the asserted rule id proves the inline +// rule fired. Detect mode is included because it is the production default and +// its CRS engine never interrupts (DetectionOnly), so the block must still +// come from the inline engine — block mode is left out precisely because there +// the CRS layer could be the one blocking. +// +// Payloads are form-urlencoded: that is the body form coraza parses into +// ARGS/REQUEST_BODY for this engine (it loads no coraza.conf-recommended, so +// no JSON body processor is selected — see waf_crs_test.go for the CRS layer, +// which does inspect JSON bodies). +func TestWAFMiddleware_BlocksAttackPayloadInRequestBody(t *testing.T) { + cases := []struct { + name string + body string + ruleID int + }{ + {"sqli", `q=1%27%20OR%20%271%27%3D%271%20--%20`, 942100}, + {"xss", `q=%3Cscript%3Ealert%281%29%3C%2Fscript%3E`, 941100}, + {"path_traversal", `q=..%2F..%2Fetc%2Fpasswd`, 930100}, + {"command_injection", `q=hello%20%7C%20id`, 932100}, + } + + for _, mode := range []string{CRSModeOff, CRSModeDetect} { + middleware := NewWAFMiddlewareCRS(2, mode) + for _, tc := range cases { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("downstream handler must not be called for a %s request body", tc.name) + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "OwnCordClient/1.0") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + out := captureSlog(t, func() { handler.ServeHTTP(rr, req) }) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", rr.Code, rr.Body.String()) + } + if strings.TrimSpace(rr.Body.String()) != `{"error":"request blocked by security rules"}` { + t.Fatalf("body = %q, want blocked JSON", rr.Body.String()) + } + if want := fmt.Sprintf("rule_id=%d", tc.ruleID); !strings.Contains(out, want) { + t.Fatalf("blocked by the wrong rule: want %s in\n%s", want, out) + } + }) + } + } +} + // Routes exempted from the app's global 1 MiB body cap (bodyCapExemptPrefixes // in constants.go) must also be exempted from the inline WAF engine's own // SecRequestBodyLimit, or coraza's default SecRequestBodyLimitAction (Reject) diff --git a/Server/auth/totp_encrypt_test.go b/Server/auth/totp_encrypt_test.go new file mode 100644 index 00000000..faeb154c --- /dev/null +++ b/Server/auth/totp_encrypt_test.go @@ -0,0 +1,252 @@ +package auth_test + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/owncord/server/auth" +) + +// testKey returns a deterministic 32-byte AES-256 key. +func testKey(fill byte) []byte { + key := make([]byte, 32) + for i := range key { + key[i] = fill + } + return key +} + +// TestEncryptDecryptTOTPSecret_RoundTrip pins the real AES-GCM path: a secret +// encrypted with a key must decrypt back to itself byte-for-byte, and the +// stored form must not be the plaintext. +func TestEncryptDecryptTOTPSecret_RoundTrip(t *testing.T) { + key := testKey(0x2a) + secrets := []string{ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", // 32-char base32 TOTP secret + "JBSWY3DPEHPK3PXP", // 16-char legacy-length secret + "", // empty plaintext still round-trips + } + + for _, secret := range secrets { + encrypted, err := auth.EncryptTOTPSecret(key, secret) + if err != nil { + t.Fatalf("EncryptTOTPSecret(%q): %v", secret, err) + } + if encrypted == secret { + t.Fatalf("EncryptTOTPSecret(%q) returned the plaintext", secret) + } + + got, err := auth.DecryptTOTPSecret(key, encrypted) + if err != nil { + t.Fatalf("DecryptTOTPSecret(%q): %v", secret, err) + } + if got != secret { + t.Fatalf("round-trip = %q, want %q", got, secret) + } + } +} + +// TestEncryptTOTPSecret_NonceIsRandom pins that two encryptions of the same +// secret differ, so a stored ciphertext cannot be used as a secret fingerprint. +func TestEncryptTOTPSecret_NonceIsRandom(t *testing.T) { + key := testKey(0x11) + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + first, err := auth.EncryptTOTPSecret(key, secret) + if err != nil { + t.Fatalf("EncryptTOTPSecret: %v", err) + } + second, err := auth.EncryptTOTPSecret(key, secret) + if err != nil { + t.Fatalf("EncryptTOTPSecret: %v", err) + } + if first == second { + t.Fatal("two encryptions of the same secret produced identical ciphertext (nonce reuse)") + } +} + +// TestDecryptTOTPSecret_FailsClosed pins the documented invariant at +// totp_encrypt.go: a value that has the full encrypted shape (valid hex, long +// enough for nonce+tag) but fails GCM authentication must return an error and +// an EMPTY string. Returning the ciphertext would silently mask a wrong +// TOTP_ENCRYPTION_KEY and hand the caller a bogus "secret". +func TestDecryptTOTPSecret_FailsClosed(t *testing.T) { + key := testKey(0x01) + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + encrypted, err := auth.EncryptTOTPSecret(key, secret) + if err != nil { + t.Fatalf("EncryptTOTPSecret: %v", err) + } + + // Flip the last ciphertext byte to simulate tampering/corruption. + raw, err := hex.DecodeString(encrypted) + if err != nil { + t.Fatalf("hex.DecodeString: %v", err) + } + raw[len(raw)-1] ^= 0xff + tampered := hex.EncodeToString(raw) + + tests := []struct { + name string + key []byte + ciphertext string + }{ + {name: "wrong key", key: testKey(0x02), ciphertext: encrypted}, + {name: "tampered ciphertext", key: key, ciphertext: tampered}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := auth.DecryptTOTPSecret(tc.key, tc.ciphertext) + if err == nil { + t.Fatalf("DecryptTOTPSecret returned nil error (got %q); must fail closed", got) + } + if got != "" { + t.Fatalf("DecryptTOTPSecret returned %q on auth failure; must return the empty string, never the ciphertext", got) + } + }) + } +} + +// TestDecryptTOTPSecret_LegacyPlaintextPassthrough pins the backwards-compat +// branches: values that cannot be encrypted data are handed back unchanged +// with no error, which is what makes the fail-closed branch above safe. +func TestDecryptTOTPSecret_LegacyPlaintextPassthrough(t *testing.T) { + key := testKey(0x03) + tests := []struct { + name string + value string + }{ + {name: "short base32 secret", value: "JBSWY3DPEHPK3PXP"}, + // Long enough for the encrypted format but not valid hex. + {name: "long non-hex value", value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBV"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := auth.DecryptTOTPSecret(key, tc.value) + if err != nil { + t.Fatalf("DecryptTOTPSecret(%q): %v", tc.value, err) + } + if got != tc.value { + t.Fatalf("DecryptTOTPSecret(%q) = %q, want the value unchanged", tc.value, got) + } + }) + } +} + +// TestLoadOrGenerateTOTPKey_StableAcrossRestarts pins that the second boot +// reads totp.key back off disk instead of generating a fresh key. A regression +// here makes every stored (encrypted) TOTP secret undecryptable after a +// restart, locking every 2FA account out. +func TestLoadOrGenerateTOTPKey_StableAcrossRestarts(t *testing.T) { + t.Setenv("OWNCORD_TOTP_KEY", "") + dataDir := filepath.Join(t.TempDir(), "data") + + first, err := auth.LoadOrGenerateTOTPKey(dataDir) + if err != nil { + t.Fatalf("first LoadOrGenerateTOTPKey: %v", err) + } + if len(first) != 32 { + t.Fatalf("key length = %d, want 32", len(first)) + } + + second, err := auth.LoadOrGenerateTOTPKey(dataDir) + if err != nil { + t.Fatalf("second LoadOrGenerateTOTPKey: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatalf("key changed across restarts: %x then %x", first, second) + } + + // The persisted key must be the one returned, so an operator copying + // totp.key to another host gets the same decryption key. + onDisk, err := os.ReadFile(filepath.Join(dataDir, "totp.key")) + if err != nil { + t.Fatalf("reading totp.key: %v", err) + } + if string(onDisk) != hex.EncodeToString(first) { + t.Fatalf("totp.key = %q, want %q", onDisk, hex.EncodeToString(first)) + } +} + +// TestLoadOrGenerateTOTPKey_EnvVar pins that OWNCORD_TOTP_KEY wins over a +// totp.key on disk (so an operator can rotate without touching the file) and +// that a wrong-length env key is a hard error rather than a silent fallback to +// auto-generation. +func TestLoadOrGenerateTOTPKey_EnvVar(t *testing.T) { + t.Run("valid hex wins over the key file", func(t *testing.T) { + envKey := testKey(0x7e) + t.Setenv("OWNCORD_TOTP_KEY", hex.EncodeToString(envKey)) + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "totp.key"), + []byte(hex.EncodeToString(testKey(0x01))), 0o600); err != nil { + t.Fatalf("writing totp.key: %v", err) + } + + got, err := auth.LoadOrGenerateTOTPKey(dataDir) + if err != nil { + t.Fatalf("LoadOrGenerateTOTPKey: %v", err) + } + if !bytes.Equal(got, envKey) { + t.Fatalf("key = %x, want the env key %x", got, envKey) + } + }) + + t.Run("wrong length is a hard error", func(t *testing.T) { + t.Setenv("OWNCORD_TOTP_KEY", hex.EncodeToString(make([]byte, 16))) + key, err := auth.LoadOrGenerateTOTPKey(t.TempDir()) + if err == nil { + t.Fatalf("LoadOrGenerateTOTPKey accepted a 16-byte OWNCORD_TOTP_KEY (returned %x)", key) + } + if key != nil { + t.Fatalf("LoadOrGenerateTOTPKey returned key %x alongside an error; want nil", key) + } + }) +} + +// TestLoadOrGenerateTOTPKey_RejectsBadKeyFile pins that a corrupt totp.key is a +// hard error rather than a silent regeneration (which would orphan every +// stored secret). +func TestLoadOrGenerateTOTPKey_RejectsBadKeyFile(t *testing.T) { + tests := []struct { + name string + contents string + }{ + {name: "invalid hex", contents: "not-hex-at-all"}, + {name: "wrong length", contents: hex.EncodeToString(make([]byte, 16))}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OWNCORD_TOTP_KEY", "") + dataDir := t.TempDir() + keyPath := filepath.Join(dataDir, "totp.key") + if err := os.WriteFile(keyPath, []byte(tc.contents), 0o600); err != nil { + t.Fatalf("writing totp.key: %v", err) + } + + key, err := auth.LoadOrGenerateTOTPKey(dataDir) + if err == nil { + t.Fatalf("LoadOrGenerateTOTPKey accepted a corrupt totp.key (returned %x)", key) + } + if key != nil { + t.Fatalf("LoadOrGenerateTOTPKey returned key %x alongside an error; want nil", key) + } + + // The corrupt file must be left alone, not overwritten with a + // freshly generated key. + after, readErr := os.ReadFile(keyPath) + if readErr != nil { + t.Fatalf("reading totp.key after failure: %v", readErr) + } + if string(after) != tc.contents { + t.Fatalf("totp.key was rewritten to %q, want %q", after, tc.contents) + } + }) + } +} diff --git a/Server/db/migrate_upgrade_test.go b/Server/db/migrate_upgrade_test.go index d1e93932..c30d72a4 100644 --- a/Server/db/migrate_upgrade_test.go +++ b/Server/db/migrate_upgrade_test.go @@ -10,8 +10,9 @@ package db_test // 2. A database that was created at an older schema point (migrations // 001..019 only, before any of the phase 2-6 additions) and already has // data in it can be upgraded by applying the remaining migrations -// (020..028) without error, and every pre-existing row survives with sane -// defaults for the newly added columns. +// (020..head) without error, and every pre-existing row survives with sane +// defaults for the newly added columns — including the attachments rows +// that migration 030 copies through a DROP/RENAME table rebuild. // // TestMigrate_022SeedsMentionEveryone and TestMigrate_022CreatesMentionSchema // in migrate_test.go already lock the mention-specific pieces in isolation; @@ -145,14 +146,19 @@ func TestMigrate_FullChainSchemaIsCoherent(t *testing.T) { // TestMigrate_UpgradeFromMigration019PreservesData simulates upgrading a // database that was last migrated at 019_perf_indexes.sql: it builds that // schema via a filtered view of the real embedded migrations, inserts a row -// each into users/roles/channels/messages/voice_states/emoji (the tables the -// 020..028 migrations touch), then applies the full chain and asserts: +// each into users/roles/channels/messages/voice_states/emoji/attachments (the +// tables the 020..head migrations touch), then applies the full chain and +// asserts: // // - the upgrade completes without error, -// - the pre-existing rows are all still present (by primary key), and +// - the pre-existing rows are all still present (by primary key), // - the new columns those rows gained have the migration's stated defaults // (0/NULL), not some other value — i.e. old data is not silently -// backfilled with something other than the documented default. +// backfilled with something other than the documented default, and +// - the attachments row survives migration 030's INSERT…SELECT + DROP + +// RENAME rebuild with every column value intact. 030 is the only +// migration that destroys and recreates a table holding user data, so it +// is the only one whose data copy can silently lose or reorder columns. func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { database := openMemory(t) ctx := context.Background() @@ -202,6 +208,16 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { `INSERT INTO emoji (id, shortcode, filename, uploaded_by) VALUES (1, 'partyparrot', 'stored-uuid', 1)`); err != nil { t.Fatalf("seed emoji: %v", err) } + // Every attachments column populated (no NULLs, no defaults) so migration + // 030's rebuild has something to lose in each of the ten positions it + // copies. + if _, err := database.ExecContext(ctx, + `INSERT INTO attachments (id, message_id, filename, stored_as, mime_type, + size, uploaded_at, width, height, uploader_id) + VALUES ('att-1', 1, 'cat.png', 'stored-cat-uuid', 'image/png', + 4242, '2024-01-02 03:04:05', 640, 480, 1)`); err != nil { + t.Fatalf("seed attachment: %v", err) + } // Apply the remaining migrations (020..028) via the real production path. if err := db.Migrate(database); err != nil { @@ -220,6 +236,7 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { {"SELECT 1 FROM messages WHERE id = ?", []any{1}, "message"}, {"SELECT 1 FROM voice_states WHERE user_id = ?", []any{1}, "voice_states"}, {"SELECT 1 FROM emoji WHERE id = ?", []any{1}, "emoji"}, + {"SELECT 1 FROM attachments WHERE id = ?", []any{"att-1"}, "attachment"}, } { var one int if err := database.QueryRowContext(ctx, tc.query, tc.args...).Scan(&one); err != nil { @@ -269,6 +286,41 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { t.Errorf("emoji.mime_type = %q for pre-existing row, want the migration's documented default %q", mimeType, "image/png") } + // Migration 030 rebuilds attachments (INSERT…SELECT into attachments_v030, + // DROP, RENAME) to swap message_id's FK action to ON DELETE SET NULL. The + // copy lists ten columns twice, so a dropped, added or reordered column + // silently corrupts every pre-existing row — assert all ten came through + // unchanged, message_id still bound to the seeded message. + var ( + attMessageID, attSize, attWidth, attHeight, attUploaderID int64 + attFilename, attStoredAs, attMimeType, attUploadedAt string + ) + if err := database.QueryRowContext(ctx, + `SELECT message_id, filename, stored_as, mime_type, size, uploaded_at, width, height, uploader_id + FROM attachments WHERE id = 'att-1'`, + ).Scan(&attMessageID, &attFilename, &attStoredAs, &attMimeType, &attSize, + &attUploadedAt, &attWidth, &attHeight, &attUploaderID); err != nil { + t.Fatalf("reading upgraded attachment: %v", err) + } + for _, tc := range []struct { + column string + got, want any + }{ + {"message_id", attMessageID, int64(1)}, + {"filename", attFilename, "cat.png"}, + {"stored_as", attStoredAs, "stored-cat-uuid"}, + {"mime_type", attMimeType, "image/png"}, + {"size", attSize, int64(4242)}, + {"uploaded_at", attUploadedAt, "2024-01-02 03:04:05"}, + {"width", attWidth, int64(640)}, + {"height", attHeight, int64(480)}, + {"uploader_id", attUploaderID, int64(1)}, + } { + if tc.got != tc.want { + t.Errorf("attachments.%s = %v after migration 030's rebuild, want %v", tc.column, tc.got, tc.want) + } + } + var mentionsEveryone int if err := database.QueryRowContext(ctx, `SELECT mentions_everyone FROM messages WHERE id = 1`).Scan(&mentionsEveryone); err != nil { @@ -298,7 +350,7 @@ func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { // Every migration file, old and new, must be recorded — this is the // upgrade path's real contract: 001..019 came from the seed/normal path - // during the first MigrateFS call, 020..028 from the second. + // during the first MigrateFS call, 020..head from the second. all, err := fs.ReadDir(migrations.FS, ".") if err != nil { t.Fatalf("reading embedded migrations dir: %v", err) diff --git a/Server/db/session_expiry_test.go b/Server/db/session_expiry_test.go index 6a05f0ba..6cba9d66 100644 --- a/Server/db/session_expiry_test.go +++ b/Server/db/session_expiry_test.go @@ -79,42 +79,55 @@ func TestDeleteExpiredSessions_SargableFormat(t *testing.T) { } } -// TestMigration031_NormalizesLegacyFormats verifies the one-time UPDATE pass: -// space-separated and Z-less rows become the RFC3339-Z layout. +// TestMigration031_NormalizesLegacyFormats drives the real migration file: +// it builds the pre-031 schema with migrationCutoffFS, seeds legacy +// space-separated and Z-less expires_at rows on it, then applies the full +// chain so migration 031's one-time UPDATE pass is what normalizes them. func TestMigration031_NormalizesLegacyFormats(t *testing.T) { - database, err := db.Open(":memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - t.Cleanup(func() { _ = database.Close() }) - if err := db.MigrateFS(database, migrations.FS); err != nil { - t.Fatalf("MigrateFS: %v", err) - } + database := openMemory(t) ctx := context.Background() + + if err := db.MigrateFS(database, migrationCutoffFS{underlying: migrations.FS, cutoff: "031_"}); err != nil { + t.Fatalf("MigrateFS building pre-031 schema: %v", err) + } + var idx int + if err := database.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_sessions_expires_at'`).Scan(&idx); err != nil { + t.Fatal(err) + } + if idx != 0 { + t.Fatal("idx_sessions_expires_at exists before 031 ran — cutoff FS leaked the migration") + } + if _, err := database.ExecContext(ctx, `INSERT INTO users (id, username, password, role_id) VALUES (1, 'u', 'x', 1)`); err != nil { t.Fatalf("seed user: %v", err) } - // Simulate pre-031 rows, then re-run the normalization statements the - // migration contains (the migration itself already ran on the empty DB). - if _, err := database.ExecContext(ctx, - `INSERT INTO sessions (user_id, token, expires_at) VALUES (1, 'legacy', '2030-05-01 10:00:00')`); err != nil { - t.Fatal(err) + cases := []struct{ token, stored, want string }{ + {"legacy_space", "2030-05-01 10:00:00", "2030-05-01T10:00:00Z"}, + {"legacy_no_z", "2030-06-02T11:22:33", "2030-06-02T11:22:33Z"}, + {"already_normalized", "2030-07-03T12:34:56Z", "2030-07-03T12:34:56Z"}, } - if _, err := database.ExecContext(ctx, - `UPDATE sessions SET expires_at = replace(expires_at, ' ', 'T') WHERE instr(expires_at, ' ') > 0`); err != nil { - t.Fatal(err) + for _, tc := range cases { + if _, err := database.ExecContext(ctx, + `INSERT INTO sessions (user_id, token, expires_at) VALUES (1, ?, ?)`, tc.token, tc.stored); err != nil { + t.Fatalf("seed session %s: %v", tc.token, err) + } } - if _, err := database.ExecContext(ctx, - `UPDATE sessions SET expires_at = expires_at || 'Z' WHERE length(expires_at) = 19`); err != nil { - t.Fatal(err) + + // Only 031 is left to apply, so any change below is its doing. + if err := db.MigrateFS(database, migrations.FS); err != nil { + t.Fatalf("MigrateFS applying 031: %v", err) } - var got string - if err := database.QueryRowContext(ctx, - `SELECT expires_at FROM sessions WHERE token = 'legacy'`).Scan(&got); err != nil { - t.Fatal(err) - } - if got != "2030-05-01T10:00:00Z" { - t.Fatalf("normalized expires_at = %q, want 2030-05-01T10:00:00Z", got) + + for _, tc := range cases { + var got string + if err := database.QueryRowContext(ctx, + `SELECT expires_at FROM sessions WHERE token = ?`, tc.token).Scan(&got); err != nil { + t.Fatalf("read %s: %v", tc.token, err) + } + if got != tc.want { + t.Errorf("%s: expires_at = %q, want %q", tc.token, got, tc.want) + } } } diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index be636984..4ef511bb 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -9,8 +9,10 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" "net/http/httptest" "os" @@ -501,6 +503,120 @@ func TestExtractChatserverFromTarGz(t *testing.T) { } } +// tarEntry is one member of a test archive; body is empty for header-only +// entries such as symlinks. +type tarEntry struct { + hdr tar.Header + body []byte +} + +func buildTarGz(t *testing.T, entries ...tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for i := range entries { + hdr := entries[i].hdr + hdr.Mode = 0o755 + hdr.Size = int64(len(entries[i].body)) + if err := tw.WriteHeader(&hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(entries[i].body); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExtractChatserverFromTarGzEntryFilters(t *testing.T) { + want := []byte("#!/bin/real\n") + wantSum := sha256.Sum256(want) + regular := tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: want} + + tests := []struct { + name string + entries []tarEntry + wantErr bool + }{ + { + // A "chatserver" shipped as a symlink must not be followed. + name: "non-regular entry is skipped", + entries: []tarEntry{{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}, regular}, + }, + { + name: "path-traversal name is skipped", + entries: []tarEntry{{hdr: tar.Header{Name: "../../chatserver", Typeflag: tar.TypeReg}, body: []byte("planted")}, regular}, + }, + { + name: "other basename is skipped", + entries: []tarEntry{{hdr: tar.Header{Name: "chatserver.sig", Typeflag: tar.TypeReg}, body: []byte("sig")}, regular}, + }, + { + name: "no chatserver member at all", + entries: []tarEntry{{hdr: tar.Header{Name: "README", Typeflag: tar.TypeReg}, body: []byte("hi")}, {hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/bin/sh"}}}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dest := filepath.Join(t.TempDir(), "chatserver") + gotHash, err := extractChatserverFromTarGz(bytes.NewReader(buildTarGz(t, tc.entries...)), dest) + if tc.wantErr { + if err == nil { + t.Fatalf("extractChatserverFromTarGz = %q, want error", gotHash) + } + if _, statErr := os.Stat(dest); !errors.Is(statErr, fs.ErrNotExist) { + t.Errorf("destPath exists after a failed extraction (stat err %v)", statErr) + } + return + } + if err != nil { + t.Fatalf("extractChatserverFromTarGz: %v", err) + } + if gotHash != hex.EncodeToString(wantSum[:]) { + t.Errorf("hash = %q, want hash of the regular chatserver entry", gotHash) + } + got, readErr := os.ReadFile(dest) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(got, want) { + t.Errorf("extracted %q, want %q", got, want) + } + }) + } +} + +// A pre-existing staging path is an attacker-planted file: staging is O_EXCL, +// so extraction must fail rather than write through it. +func TestExtractChatserverFromTarGzRefusesExistingDest(t *testing.T) { + dest := filepath.Join(t.TempDir(), "chatserver") + planted := []byte("planted-by-attacker") + if err := os.WriteFile(dest, planted, 0o600); err != nil { + t.Fatal(err) + } + + archive := buildTarGz(t, tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: []byte("#!/bin/real\n")}) + if _, err := extractChatserverFromTarGz(bytes.NewReader(archive), dest); !errors.Is(err, fs.ErrExist) { + t.Fatalf("extractChatserverFromTarGz err = %v, want fs.ErrExist", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, planted) { + t.Errorf("pre-existing file was overwritten: %q", got) + } +} + func TestAssetFilenameFromURL(t *testing.T) { got, err := assetFilenameFromURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe") if err != nil { diff --git a/Server/ws/deps.go b/Server/ws/deps.go index 6a050d54..6aab3c9b 100644 --- a/Server/ws/deps.go +++ b/Server/ws/deps.go @@ -84,11 +84,20 @@ type KeyHolderChecker interface { // it live at dispatch time picks up the late wiring. MessageSvc gates channel // broadcasts through the same posting policy as a real message send. type PluginDeps struct { - Registry func() *plugin.Registry + Registry func() CommandDispatcher MessageSvc *service.MessageService Limiter *auth.RateLimiter } +// CommandDispatcher is the one method the chat_command handler needs from the +// plugin registry; *plugin.Registry satisfies it. Taking the interface rather +// than the concrete type is what makes the broadcast path testable: without +// the wazero build tag a real registry has no runtime and can only ever answer +// with a Reply, so the CanPost gate would otherwise be unreachable from a test. +type CommandDispatcher interface { + DispatchCommand(ctx context.Context, userID, channelID int64, cmd string, args []string) (*plugin.CommandResult, bool) +} + // VoiceDeps holds dependencies for voice handlers. type VoiceDeps struct { DB *db.DB diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 3ceee54a..e41d8606 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -407,14 +407,28 @@ func (h *Hub) HandleWebhookParticipantLeftWithContextForTest(ctx context.Context // for external tests. identity and roomName are passed raw so a test can feed // malformed values through the same parse path a hostile webhook would. func (h *Hub) HandleWebhookParticipantJoinedForTest(identity, roomName string) { + h.HandleWebhookParticipantJoinedWithContextForTest(context.Background(), identity, roomName) +} + +// HandleWebhookParticipantJoinedWithContextForTest is +// HandleWebhookParticipantJoinedForTest with a caller-supplied context, so +// external tests can simulate the webhook HTTP handler's request context +// (e.g. already-cancelled, as it would be after the webhook sender hangs up) +// instead of always running with context.Background(). Mirrors +// HandleWebhookParticipantLeftWithContextForTest. +func (h *Hub) HandleWebhookParticipantJoinedWithContextForTest(ctx context.Context, identity, roomName string) { event := &livekit.WebhookEvent{ Event: "participant_joined", Participant: &livekit.ParticipantInfo{Identity: identity}, Room: &livekit.Room{Name: roomName}, } - h.handleWebhookParticipantJoined(context.Background(), event) + h.handleWebhookParticipantJoined(ctx, event) } +// WebhookMaxBodyBytesForTest exposes the webhook body cap so external tests can +// build a body that is over it without hardcoding the constant twice. +const WebhookMaxBodyBytesForTest = webhookMaxBodyBytes + // HandleWebhookParticipantJoinedEventForTest exposes // handleWebhookParticipantJoined with a caller-built event so tests can cover // the nil-participant and nil-room guards. diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index ff88587a..674d4e1a 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -17,7 +17,6 @@ import ( "time" "github.com/owncord/server/auth" - "github.com/owncord/server/plugin" "github.com/owncord/server/service" ) @@ -50,7 +49,7 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many commands"}} } - var reg *plugin.Registry + var reg CommandDispatcher if d.Registry != nil { reg = d.Registry() } diff --git a/Server/ws/handlers_command_gate_test.go b/Server/ws/handlers_command_gate_test.go new file mode 100644 index 00000000..6c9b118c --- /dev/null +++ b/Server/ws/handlers_command_gate_test.go @@ -0,0 +1,192 @@ +package ws + +// handlers_command_gate_test.go — success path of the chat_command handler: +// the ephemeral reply, the MessageService.CanPost broadcast gate, and the +// plugin_broadcast fan-out. handlers_command_test.go (package ws_test) covers +// only the refusals, which it can reach through the hub; these need PluginDeps +// and the unexported command struct, so they live in-package. + +import ( + "context" + "encoding/json" + "testing" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/plugin" + "github.com/owncord/server/service" +) + +// stubDispatcher stands in for *plugin.Registry. A real registry is useless +// here: without the wazero build tag it has no runtime, so DispatchCommand can +// only ever return the "runtime is not built" Reply — never a Broadcast, and +// therefore never the CanPost gate below. +type stubDispatcher struct { + result *plugin.CommandResult + handled bool +} + +func (s stubDispatcher) DispatchCommand(_ context.Context, _, _ int64, _ string, _ []string) (*plugin.CommandResult, bool) { + return s.result, s.handled +} + +// newCommandTestDeps builds PluginDeps whose MessageSvc is the real service +// (the same CanPost a message send runs) over an in-memory DB, plus a +// dispatcher stub returning res. Returns the owner (all permissions), a user +// whose role carries none, and a text channel. +func newCommandTestDeps(t *testing.T, res *plugin.CommandResult) (deps PluginDeps, ownerID, mutedID, chID int64) { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + ctx := context.Background() + if ownerID, err = database.CreateUser(ctx, "cmd-owner", "hash", 1); err != nil { // Owner role + t.Fatalf("CreateUser owner: %v", err) + } + role, err := database.CreateRole(ctx, "cmd-muted", nil, 0, 0) // no permission bits + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if mutedID, err = database.CreateUser(ctx, "cmd-muted-user", "hash", int(role.ID)); err != nil { + t.Fatalf("CreateUser muted: %v", err) + } + if chID, err = database.CreateChannel(ctx, "cmd-chan", "text", "", "", 0); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + svc := service.New(database, auth.NewRateLimiter()) + deps = PluginDeps{ + Registry: func() CommandDispatcher { return stubDispatcher{result: res, handled: true} }, + MessageSvc: svc.Messages, + } + return deps, ownerID, mutedID, chID +} + +// A plugin Reply becomes an ephemeral command_reply envelope carrying the +// request's req_id, and reaches nobody else. +func TestHandleChatCommandV2_ReplyIsEphemeral(t *testing.T) { + deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "pong"}) + cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/ping", reqID: "req-7"} + + result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if len(result.Events) != 0 { + t.Fatalf("a reply-only command must not broadcast, got %d events", len(result.Events)) + } + var env struct { + Type string `json:"type"` + ReqID string `json:"req_id"` + Payload struct { + Text string `json:"text"` + } `json:"payload"` + } + if err := json.Unmarshal(result.Reply, &env); err != nil { + t.Fatalf("unmarshal reply %s: %v", result.Reply, err) + } + if env.Type != MsgTypeCommandReply { + t.Errorf("type = %q, want %q", env.Type, MsgTypeCommandReply) + } + if env.ReqID != "req-7" { + t.Errorf("req_id = %q, want req-7", env.ReqID) + } + if env.Payload.Text != "pong" { + t.Errorf("text = %q, want pong", env.Payload.Text) + } +} + +// An authorized user's Broadcast fans out as a plugin_broadcast event on the +// invoking channel. +func TestHandleChatCommandV2_BroadcastFansOutWhenAllowed(t *testing.T) { + deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "rolled a 6"}) + cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/roll", reqID: "req-8"} + + result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if len(result.Events) != 1 { + t.Fatalf("expected 1 broadcast event, got %d", len(result.Events)) + } + ev, ok := result.Events[0].(PluginBroadcastEvent) + if !ok { + t.Fatalf("expected PluginBroadcastEvent, got %T", result.Events[0]) + } + if ev.ChannelID() != chID { + t.Errorf("event channel = %d, want %d", ev.ChannelID(), chID) + } + var env struct { + Type string `json:"type"` + Payload struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + Command string `json:"command"` + Text string `json:"text"` + } `json:"payload"` + } + if err := json.Unmarshal(ev.Payload(), &env); err != nil { + t.Fatalf("unmarshal payload %s: %v", ev.Payload(), err) + } + if env.Type != MsgTypePluginBroadcast { + t.Errorf("type = %q, want %q", env.Type, MsgTypePluginBroadcast) + } + if env.Payload.ChannelID != chID || env.Payload.UserID != ownerID { + t.Errorf("payload ids = (%d,%d), want (%d,%d)", env.Payload.ChannelID, env.Payload.UserID, chID, ownerID) + } + if env.Payload.Command != "/roll" || env.Payload.Text != "rolled a 6" { + t.Errorf("payload = %+v, want command=/roll text=rolled a 6", env.Payload) + } +} + +// The CanPost gate: a user whose role cannot post gets FORBIDDEN and nothing +// reaches the channel — even though the plugin returned a broadcast. The reply +// is dropped with it (the denial is the security signal; see handlers_command.go). +func TestHandleChatCommandV2_BroadcastDeniedWithoutPostPermission(t *testing.T) { + deps, _, mutedID, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "ok", Broadcast: "rolled a 6"}) + cmd := ChatCommandCmd{userID: mutedID, channelID: chID, command: "/roll", reqID: "req-9"} + + result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: mutedID}, deps) + + ce, ok := result.Error.(ClientError) + if !ok { + t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error) + } + if ce.Code != ErrCodeForbidden { + t.Errorf("code = %q, want %q", ce.Code, ErrCodeForbidden) + } + if len(result.Events) != 0 { + t.Errorf("denied command must not broadcast, got %d events", len(result.Events)) + } + if result.Reply != nil { + t.Errorf("denied command must not also reply, got %s", result.Reply) + } +} + +// A broadcast aimed at a channel that does not exist is NOT_FOUND, not +// FORBIDDEN — CanPost's missing-channel branch. +func TestHandleChatCommandV2_BroadcastUnknownChannel(t *testing.T) { + deps, ownerID, _, _ := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "hi"}) + cmd := ChatCommandCmd{userID: ownerID, channelID: 424242, command: "/roll"} + + result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps) + + ce, ok := result.Error.(ClientError) + if !ok { + t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error) + } + if ce.Code != ErrCodeNotFound { + t.Errorf("code = %q, want %q", ce.Code, ErrCodeNotFound) + } + if len(result.Events) != 0 { + t.Errorf("expected no events, got %d", len(result.Events)) + } +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index ab2e9076..fd79179f 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -188,7 +188,15 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * // Phase C Step 9 — plugin slash commands. Registry is read live because // SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts. reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{ - Registry: func() *plugin.Registry { return h.pluginRegistry }, + // A nil registry must yield a nil interface, not a typed-nil + // *plugin.Registry — the handler's "no plugins loaded" check is an + // interface comparison. + Registry: func() CommandDispatcher { + if h.pluginRegistry == nil { + return nil + } + return h.pluginRegistry + }, MessageSvc: h.messageSvc, Limiter: h.limiter, }) diff --git a/Server/ws/hub_broadcast_test.go b/Server/ws/hub_broadcast_test.go index d0ccaaef..7f720968 100644 --- a/Server/ws/hub_broadcast_test.go +++ b/Server/ws/hub_broadcast_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "bytes" "context" "encoding/json" "testing" @@ -15,22 +16,29 @@ import ( // every connected client — an identity key that fails to propagate silently // breaks E2EE key agreement for everyone already online. -// awaitMessage reads one message from ch, failing if none arrives. -func awaitMessage(t *testing.T, ch chan []byte) map[string]any { +// awaitRawMessage reads one raw frame from ch, failing if none arrives. +func awaitRawMessage(t *testing.T, ch chan []byte) []byte { t.Helper() select { case raw := <-ch: - var msg map[string]any - if err := json.Unmarshal(raw, &msg); err != nil { - t.Fatalf("unmarshal %q: %v", raw, err) - } - return msg + return raw case <-time.After(2 * time.Second): t.Fatal("no message received") return nil } } +// awaitMessage reads one message from ch, failing if none arrives. +func awaitMessage(t *testing.T, ch chan []byte) map[string]any { + t.Helper() + raw := awaitRawMessage(t, ch) + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("unmarshal %q: %v", raw, err) + } + return msg +} + func TestHub_BroadcastUserUpdate(t *testing.T) { hub, _ := newTestHub(t) go hub.Run() @@ -186,14 +194,123 @@ func TestHub_ChannelReadAudience_ExcludesArchivedChannel(t *testing.T) { assertNotReceived(t, send, "member with base READ_MESSAGES on an archived channel") } +// stubDMEvent is a minimal ws.SequencedDMEvent so EmitEvents routes through +// sendSequencedToUsers — persistEvent's second call site, the one that stamps +// a non-zero channel_id without going through the broadcast queue. +type stubDMEvent struct { + channelID int64 + participantIDs []int64 + payload []byte +} + +func (e stubDMEvent) EventType() string { return "chat_message" } +func (e stubDMEvent) ChannelID() int64 { return e.channelID } +func (e stubDMEvent) ParticipantIDs() []int64 { return e.participantIDs } +func (e stubDMEvent) Payload() []byte { return e.payload } + +// TestHub_SetEventPersister pins the invariant persistEvent exists for: the +// row written to the EventStore carries the same seq (and type, and channel) +// as the wrapped payload the client received — from both call sites, +// deliverBroadcast and sendSequencedToUsers. Cold-tier reconnect replay +// selects rows by row-seq against the payload-seq the client acked, so a +// mismatch silently replays the wrong window. func TestHub_SetEventPersister(t *testing.T) { hub, database := newTestHub(t) - persister := ws.NewEventPersister(database, 16, 4, 10*time.Millisecond) + // The persister needs the real events table; the hub's own test schema has + // none, so the store is a separately migrated DB. + store := openEventStoreDB(t) + persister := ws.NewEventPersister(store, 64, 1, 5*time.Millisecond) + persister.Start(context.Background()) // Setting and clearing must both be safe — SetEventPersister is called at // startup and again on shutdown/reconfiguration. hub.SetEventPersister(persister) hub.SetEventPersister(nil) hub.SetEventPersister(persister) + + go hub.Run() + t.Cleanup(func() { + hub.Stop() + persister.Stop(context.Background()) + }) + + user := seedMemberUser(t, database, "persisted-event-member") + chID := seedTestChannel(t, database, "persisted-event-dm") + + send := make(chan []byte, 8) + hub.RegisterNowForTest(ws.NewTestClient(hub, user.ID, send)) + + // Global broadcast → deliverBroadcast → persistEvent(seq, 0, wrapped). + hub.BroadcastToAll([]byte(`{"type":"user_update","payload":{"user_id":7}}`)) + globalFrame := awaitRawMessage(t, send) + + // Sequenced DM → sendSequencedToUsers → persistEvent(seq, chID, wrapped). + hub.EmitEvents(context.Background(), []ws.Event{stubDMEvent{ + channelID: chID, + participantIDs: []int64{user.ID}, + payload: []byte(`{"type":"chat_message","payload":{"id":1}}`), + }}) + dmFrame := awaitRawMessage(t, send) + + // Stop drains the queue and waits for the flusher to exit, so everything + // enqueued above is on disk once it returns. The cleanup's second Stop is + // a no-op. + persister.Stop(context.Background()) + + stored, err := store.GetEventsSince(context.Background(), 0, 100) + if err != nil { + t.Fatalf("GetEventsSince: %v", err) + } + bySeq := make(map[int64]db.PersistedEvent, len(stored)) + seen := make([]int64, 0, len(stored)) + for _, row := range stored { + bySeq[row.Seq] = row + seen = append(seen, row.Seq) + } + + cases := []struct { + label string + frame []byte + eventType string + channelID int64 + }{ + {"global broadcast", globalFrame, "user_update", 0}, + {"sequenced DM", dmFrame, "chat_message", chID}, + } + seqs := make([]int64, 0, len(cases)) + for _, tc := range cases { + var wire struct { + Seq int64 `json:"seq"` + Type string `json:"type"` + } + if err := json.Unmarshal(tc.frame, &wire); err != nil { + t.Fatalf("%s: unmarshal %q: %v", tc.label, tc.frame, err) + } + if wire.Seq == 0 { + t.Fatalf("%s: delivered frame carries no seq: %s", tc.label, tc.frame) + } + if wire.Type != tc.eventType { + t.Fatalf("%s: frame type = %q, want %q", tc.label, wire.Type, tc.eventType) + } + seqs = append(seqs, wire.Seq) + + row, ok := bySeq[wire.Seq] + if !ok { + t.Fatalf("%s: no persisted row at the delivered seq %d (stored seqs %v)", + tc.label, wire.Seq, seen) + } + if row.EventType != tc.eventType { + t.Errorf("%s: row event_type = %q, want %q", tc.label, row.EventType, tc.eventType) + } + if row.ChannelID != tc.channelID { + t.Errorf("%s: row channel_id = %d, want %d", tc.label, row.ChannelID, tc.channelID) + } + if !bytes.Equal(row.Payload, tc.frame) { + t.Errorf("%s: row payload = %s, want the delivered frame %s", tc.label, row.Payload, tc.frame) + } + } + if seqs[1] <= seqs[0] { + t.Errorf("seqs not monotonic across the two persist call sites: %v", seqs) + } } diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index d1122a55..f9e86a8a 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -2,7 +2,10 @@ package ws_test import ( "context" + "crypto/sha256" + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -1268,6 +1271,121 @@ func TestWebhookHandler_EmptyBody(t *testing.T) { } } +// signedWebhookRequest builds the request LiveKit itself would send: the body +// hashed with sha256, that hash carried as the token's sha256 claim, and the +// token signed with the shared secret. bodyToSign is what the token commits +// to; bodySent is what actually travels — passing different values simulates a +// captured token replayed against a forged payload. +func signedWebhookRequest(t *testing.T, apiKey, apiSecret, bodyToSign, bodySent string) *http.Request { + t.Helper() + + sum := sha256.Sum256([]byte(bodyToSign)) + token, err := auth.NewAccessToken(apiKey, apiSecret). + SetValidFor(5 * time.Minute). + SetSha256(base64.StdEncoding.EncodeToString(sum[:])). + ToJWT() + if err != nil { + t.Fatalf("minting webhook token: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/livekit/webhook", strings.NewReader(bodySent)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/webhook+json") + return req +} + +// webhookBody renders the protojson payload LiveKit posts for a participant +// event. pad is an unknown field the parser discards, used only to push the +// body past the size cap. +func webhookBody(event string, userID, channelID int64, joinToken, pad string) string { + return fmt.Sprintf( + `{"event":%q,"room":{"name":%q},"participant":{"identity":%q},"pad":%q}`, + event, ws.RoomName(channelID), participantIdentityFor(userID, joinToken), pad) +} + +// TestWebhookHandler_SignedParticipantLeftDispatches is the only webhook test +// that gets past ReceiveWebhookEvent: it mints a real LiveKit webhook token +// over the real body and asserts the handler both dispatches the event (the +// voice_states row is cleared) and answers 200. Without it, the 401 tests above +// would all still pass if verification were changed to reject everything. +func TestWebhookHandler_SignedParticipantLeftDispatches(t *testing.T) { + t.Parallel() + + const apiKey, apiSecret = "webhook-signed-key", "webhook-signed-secret-0123456789" + + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "webhook-signed-user") + chanID := seedVoiceChan(t, database, "webhook-signed-ch") + + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil || state == nil { + t.Fatalf("GetVoiceState: %v (nil=%v)", err, state == nil) + } + + body := webhookBody("participant_left", user.ID, chanID, state.JoinedAt, "") + rec := httptest.NewRecorder() + hub.NewLiveKitWebhookHandler(apiKey, apiSecret)(rec, + signedWebhookRequest(t, apiKey, apiSecret, body, body)) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for a correctly signed webhook, got %d (%s)", + rec.Code, strings.TrimSpace(rec.Body.String())) + } + + after, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState after webhook: %v", err) + } + if after != nil { + t.Errorf("participant_left verified but never dispatched: voice state still present (channel %d)", + after.ChannelID) + } +} + +// TestWebhookHandler_SignedRequestRejections covers the two ways a request +// carrying a genuinely signed token must still be refused: the token's +// body-hash claim not matching the body it arrived with (a captured token +// replayed against a forged payload), and a body past webhookMaxBodyBytes. +func TestWebhookHandler_SignedRequestRejections(t *testing.T) { + t.Parallel() + + const apiKey, apiSecret = "webhook-reject-key", "webhook-reject-secret-0123456789" + + signed := webhookBody("participant_left", 7, 42, "tok", "") + // Same token, different body: only the sha256 claim binding catches this. + mutated := webhookBody("participant_left", 8, 42, "tok", "") + // Correctly signed, but larger than webhookMaxBodyBytes — only the + // MaxBytesReader cap catches this one. + oversize := webhookBody("participant_left", 7, 42, "tok", strings.Repeat("a", ws.WebhookMaxBodyBytesForTest)) + + tests := []struct { + name string + bodyToSign string + bodySent string + }{ + {"token replayed against a mutated body", signed, mutated}, + {"body over webhookMaxBodyBytes", oversize, oversize}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hub := ws.NewHubForTest() + rec := httptest.NewRecorder() + hub.NewLiveKitWebhookHandler(apiKey, apiSecret)(rec, + signedWebhookRequest(t, apiKey, apiSecret, tt.bodyToSign, tt.bodySent)) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } + }) + } +} + // --------------------------------------------------------------------------- // livekit_webhook.go – MountWebhookRoute tests // --------------------------------------------------------------------------- diff --git a/Server/ws/livekit_webhook_joined_test.go b/Server/ws/livekit_webhook_joined_test.go index a50f6705..4c70540d 100644 --- a/Server/ws/livekit_webhook_joined_test.go +++ b/Server/ws/livekit_webhook_joined_test.go @@ -141,6 +141,43 @@ func TestWebhook_ParticipantJoined_TransientReadErrorDoesNotEvict(t *testing.T) } } +// TestWebhook_ParticipantJoined_SurvivesCancelledRequestContext locks the +// participant_joined half of OC-0018 (the participant_left half is locked by +// TestWebhook_ParticipantLeft_SurvivesCancelledRequestContext in +// livekit_test.go). Without the context.WithoutCancel detach, a webhook sender +// (LiveKit) that hangs up mid-request cancels r.Context(); GetVoiceState then +// fails, the handler takes the "transient read failure" branch and skips the +// rogue-participant check entirely — so a participant presenting a replayed +// join token is never removed from the SFU. +func TestWebhook_ParticipantJoined_SurvivesCancelledRequestContext(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "joined-ctxcancel-user") + chanID := seedVoiceChan(t, database, "joined-ctxcancel-ch") + + // Simulate net/http cancelling the request context because the webhook + // sender hung up before the handler finished. + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + logs := captureLogs(t) + + // No voice_states row exists for this user — the join is unauthorized and + // must still be flagged and evicted on a dead request context. + hub.HandleWebhookParticipantJoinedWithContextForTest( + cancelledCtx, + participantIdentityFor(user.ID, "replayed-token"), + roomNameFor(chanID), + ) + + out := logs() + if strings.Contains(out, "skipping rogue-participant check") { + t.Errorf("the cancelled request context was mistaken for a transient DB failure, so the rogue participant was never evicted; log:\n%s", out) + } + if !strings.Contains(out, "rogue participant_joined") { + t.Errorf("no rogue-participant warning logged on a cancelled request context; got:\n%s", out) + } +} + func TestWebhook_ParticipantJoined_WrongChannelFlagged(t *testing.T) { hub, database := newVoiceHub(t) user := seedVoiceOwner(t, database, "joined-wrongch-user") diff --git a/Server/ws/voice_audience_test.go b/Server/ws/voice_audience_test.go index d1a66e46..b2b72420 100644 --- a/Server/ws/voice_audience_test.go +++ b/Server/ws/voice_audience_test.go @@ -4,6 +4,8 @@ import ( "context" "slices" "testing" + + "github.com/owncord/server/auth" ) // Voice membership is gated on CONNECT_VOICE alone (voice_join), but the @@ -108,3 +110,72 @@ func TestFinishVoiceLeave_EvictedUserAlwaysInAudience(t *testing.T) { t.Fatal("finishVoiceLeave enqueued nothing") } } + +// Both DB-error branches of the READ-audience resolver must deny. The role +// scan underneath them treats whatever channel it is handed as a readable +// non-DM channel, so an unreadable channels row — or an unreadable DM +// participant list — that fell through would resolve to every connected user +// holding base READ_MESSAGES, fanning a private room's voice_state / +// voice_leave out server-wide. +func TestChannelReadAudience_GetChannelErrorDeniesEveryone(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "audience-chan-err") + chID := mustCreateVoiceChannel(t, database, "audience-room") + + h := NewHub(database, auth.NewRateLimiter(), nil) + h.clients[uid] = NewTestClient(h, uid, make(chan []byte, 8)) + + // Precondition: the role scan really does grant this user READ on this + // channel, so an empty audience after the fault can only be the deny. + if got := h.channelReadAudience(ctx, chID); !slices.Contains(got, uid) { + t.Fatalf("precondition: user %d must be in the READ audience, got %v", uid, got) + } + + // Make exactly GetChannel fail; roles and channel_overrides keep + // resolving, so the role scan would still return this user. + if _, err := database.ExecContext(ctx, `ALTER TABLE channels RENAME TO channels_offline`); err != nil { + t.Fatalf("rename channels: %v", err) + } + + if got := h.channelReadAudience(ctx, chID); len(got) != 0 { + t.Errorf("channelReadAudience resolved %v for an unreadable channel row — an unresolvable channel must deny, not fall through to the role scan", got) + } +} + +// The DM half of the same rule: a DM carries no channel_overrides rows, so its +// participant list is the only membership evidence there is. When that read +// fails there is nothing left to filter on and the audience must be empty. +func TestChannelReadAudience_DMParticipantsErrorDeniesEveryone(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + alice := seedHarvestVoiceUser(t, database, "audience-dm-alice") + bob := seedHarvestVoiceUser(t, database, "audience-dm-bob") + mallory := seedHarvestVoiceUser(t, database, "audience-dm-mallory") + dm, _, err := database.GetOrCreateDMChannel(ctx, alice, bob) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + for _, uid := range []int64{alice, bob, mallory} { + h.clients[uid] = NewTestClient(h, uid, make(chan []byte, 8)) + } + + // Precondition: the DM resolves to its participants only — mallory is + // connected and holds base READ_MESSAGES, but is not in this DM. + if got := h.channelReadAudience(ctx, dm.ID); !slices.Contains(got, alice) || + !slices.Contains(got, bob) || slices.Contains(got, mallory) { + t.Fatalf("precondition: DM audience must be exactly participants %d and %d, got %v", alice, bob, got) + } + + // Make exactly GetDMParticipantIDs fail; the channels row still resolves + // as type "dm", so the resolver reaches the DM branch and nothing else. + if _, err := database.ExecContext(ctx, `ALTER TABLE dm_participants RENAME TO dm_participants_offline`); err != nil { + t.Fatalf("rename dm_participants: %v", err) + } + + if got := h.channelReadAudience(ctx, dm.ID); len(got) != 0 { + t.Errorf("channelReadAudience resolved %v for a DM whose participant list could not be read — an unresolvable DM must deny", got) + } +} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index 19450e97..3d23406b 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -3,6 +3,7 @@ package ws_test import ( "context" "encoding/json" + "strings" "testing" "testing/fstest" "time" @@ -647,27 +648,47 @@ func TestVoice_Camera_DisableAllowedAfterPermissionRevoked(t *testing.T) { } } -// TestVoice_Camera_NoPermission: Member without USE_VIDEO gets FORBIDDEN. -func TestVoice_Camera_NoPermission(t *testing.T) { - hub, _ := newVoiceHub(t) +// assertStreamPermissionRefused fails unless msgs carry a FORBIDDEN error +// naming perm. A bare "an error arrived" check is not enough here: the stream +// handlers refuse an un-joined client with VOICE_ERROR "not in a voice channel" +// long before the permission gate runs, which is exactly how the camera and +// screenshare permission tests used to pass without ever reaching it. +func assertStreamPermissionRefused(t *testing.T, msgs [][]byte, perm string) { + t.Helper() + for _, m := range msgs { + if extractCode(t, m) == ws.ErrCodeForbidden && + strings.Contains(extractMessage(t, m), perm) { + return + } + } + got := make([]string, 0, len(msgs)) + for _, m := range msgs { + got = append(got, extractType(t, m)+"/"+extractCode(t, m)+"/"+extractMessage(t, m)) + } + t.Errorf("expected FORBIDDEN error naming %s, got %v", perm, got) +} - // Client with no user set → hasChannelPerm returns false. - send := make(chan []byte, 16) - c := ws.NewTestClient(hub, 7001, send) - hub.Register(c) - waitRegistered(t, hub, c) +// TestVoice_Camera_NoPermission: a member already in voice whose role lacks +// USE_VIDEO is refused with FORBIDDEN and stays off camera. +func TestVoice_Camera_NoPermission(t *testing.T) { + hub, database := newVoiceHub(t) + + // Role 4 (Member) carries CONNECT_VOICE but neither USE_VIDEO nor + // SHARE_SCREEN, so voice_join succeeds and only the toggle is denied. + user := seedVoiceUserWithRole(t, database, "cam-noperm", 4) + chanID := seedVoiceChan(t, database, "vc-cam-noperm") + c, send := joinVoice(t, hub, user, chanID) hub.HandleMessageForTest(c, voiceCameraMsg(true)) - msgs := drainChanTimeout(send, 30*time.Millisecond) - found := false - for _, m := range msgs { - if extractType(t, m) == "error" { - found = true - } + assertStreamPermissionRefused(t, drainChanTimeout(send, 30*time.Millisecond), "USE_VIDEO") + + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) } - if !found { - t.Error("expected FORBIDDEN error for camera toggle without USE_VIDEO permission") + if state == nil || state.Camera { + t.Error("camera enabled despite the missing USE_VIDEO permission") } } @@ -834,27 +855,25 @@ func TestVoice_Screenshare_DisableAllowedAfterPermissionRevoked(t *testing.T) { } } -// TestVoice_Screenshare_NoPermission: client without SHARE_SCREEN gets FORBIDDEN. +// TestVoice_Screenshare_NoPermission: a member already in voice whose role +// lacks SHARE_SCREEN is refused with FORBIDDEN and publishes nothing. func TestVoice_Screenshare_NoPermission(t *testing.T) { - hub, _ := newVoiceHub(t) + hub, database := newVoiceHub(t) - // Client with no user set → hasChannelPerm returns false. - send := make(chan []byte, 16) - c := ws.NewTestClient(hub, 7002, send) - hub.Register(c) - waitRegistered(t, hub, c) + user := seedVoiceUserWithRole(t, database, "ss-noperm", 4) // Member: no SHARE_SCREEN + chanID := seedVoiceChan(t, database, "vc-ss-noperm") + c, send := joinVoice(t, hub, user, chanID) hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) - msgs := drainChanTimeout(send, 30*time.Millisecond) - found := false - for _, m := range msgs { - if extractType(t, m) == "error" { - found = true - } + assertStreamPermissionRefused(t, drainChanTimeout(send, 30*time.Millisecond), "SHARE_SCREEN") + + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) } - if !found { - t.Error("expected FORBIDDEN error for screenshare toggle without SHARE_SCREEN permission") + if state == nil || state.Screenshare { + t.Error("screenshare enabled despite the missing SHARE_SCREEN permission") } } diff --git a/Server/ws/voice_moderation_test.go b/Server/ws/voice_moderation_test.go index 3d94544a..3bf40ab3 100644 --- a/Server/ws/voice_moderation_test.go +++ b/Server/ws/voice_moderation_test.go @@ -11,6 +11,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/ws" ) @@ -573,6 +574,54 @@ func TestVoiceMod_Move_ArchivedDestination_BadRequest(t *testing.T) { } } +// TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden locks the +// destination gate that is evaluated against the TARGET's access, not the +// moderator's: a move must not become a way to place someone in a channel they +// could not join themselves. The actor keeps MUTE_MEMBERS and still outranks +// the target, so only the target's missing CONNECT_VOICE can refuse this. +func TestVoiceMod_Move_TargetCannotConnectToDestination_Forbidden(t *testing.T) { + hub, database := newVoiceModHub(t) + fromID := seedVoiceChan(t, database, "vc-move-noconnect-from") + toID := seedVoiceChan(t, database, "vc-move-noconnect-to") + actor := seedVoiceUserWithRole(t, database, "admin-move-noconnect", 2) + target := seedVoiceUserWithRole(t, database, "member-move-noconnect", 4) + + // Destination denies CONNECT_VOICE to the target's role (Member, id 4). + if err := database.UpsertChannelOverride( + context.Background(), toID, 4, 0, permissions.ConnectVoice, + ); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + _, targetSend := joinVoice(t, hub, target, fromID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, fromID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID)) + + if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" { + t.Fatalf("error code = %q, want FORBIDDEN", code) + } + if payload := receiveMsgOfType(targetSend, "voice_moved", 100*time.Millisecond); payload != nil { + t.Errorf("target must not receive voice_moved for a refused move, got %v", payload) + } + state, err := database.GetVoiceState(context.Background(), target.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("a refused move must leave the target in voice") + } else if state.ChannelID != fromID { + t.Errorf("target channel = %d, want %d (unchanged)", state.ChannelID, fromID) + } + if slices.Contains(auditActions(t, database), "voice_mod_move") { + t.Error("a refused move must not write a voice_mod_move audit entry") + } +} + // TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel locks the fix for // v024: voiceModTarget authorizes against a DB snapshot, but the eviction ran // through the unscoped VoiceModerator.DisconnectFromVoice, which drops the diff --git a/Server/ws/voice_rate_limits_test.go b/Server/ws/voice_rate_limits_test.go new file mode 100644 index 00000000..113c0c06 --- /dev/null +++ b/Server/ws/voice_rate_limits_test.go @@ -0,0 +1,133 @@ +package ws + +// voice_rate_limits_test.go — the refusal branch of the voice limiters that +// had no coverage: voice_join's precheck (voice_join.go), the shared +// voice_mute/voice_deafen self-toggle (voice_controls.go) and +// voice_e2ee_announce (voice_e2ee.go). Their siblings (voice_leave, +// camera/screenshare, the e2ee offer budgets, plugin_cmd) are all already +// pinned; these three were the gap. + +import ( + "context" + "encoding/json" + "testing" + + "github.com/owncord/server/auth" +) + +// decodeErrorFrame extracts the code from a server->client error envelope. +func decodeErrorFrame(t *testing.T, frame []byte) string { + t.Helper() + var env struct { + Type string `json:"type"` + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(frame, &env); err != nil { + t.Fatalf("unmarshal frame %s: %v", frame, err) + } + if env.Type != MsgTypeError { + t.Fatalf("expected an error frame, got type %q (%s)", env.Type, frame) + } + return env.Payload.Code +} + +// voice_join fans a voice_state broadcast out to every connected client, so +// the limiter is consulted first — before the payload is even parsed. A +// payload that could never join therefore still burns a token, and once the +// budget is gone the refusal is RATE_LIMITED rather than the parse error. +func TestVoiceJoinPrecheck_RateLimited(t *testing.T) { + h := &Hub{limiter: auth.NewRateLimiter()} + send := make(chan []byte, voiceJoinRateLimit+2) + c := &Client{userID: 1, send: send, sendHigh: send, sendLow: send} + payload := json.RawMessage(`{"channel_id":"not-an-int"}`) + + for i := range voiceJoinRateLimit + 1 { + if _, _, ok := h.voiceJoinPrecheck(context.Background(), c, payload); ok { + t.Fatalf("call %d: precheck passed on a malformed payload", i) + } + } + + if got := len(send); got != voiceJoinRateLimit+1 { + t.Fatalf("queued %d error frames, want %d", got, voiceJoinRateLimit+1) + } + for i := range voiceJoinRateLimit { + if code := decodeErrorFrame(t, <-send); code != ErrCodeBadRequest { + t.Errorf("call %d: code = %q, want %q (limit not yet reached)", i, code, ErrCodeBadRequest) + } + } + if code := decodeErrorFrame(t, <-send); code != ErrCodeRateLimited { + t.Errorf("call past the budget: code = %q, want %q", code, ErrCodeRateLimited) + } +} + +// The V2 voice handlers whose rate-limit refusal was unexercised. Each case +// runs with VoiceChannelID=0 so the calls that pass the limiter stop at the +// next gate (VOICE_ERROR) instead of reaching the DB — which also pins the +// ordering: the limiter runs before the in-voice check. +func TestVoiceHandlersV2_RateLimited(t *testing.T) { + ctx := context.Background() + info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 0} + + tests := []struct { + name string + limit int + wantMsg string + call func(VoiceDeps) Result + }{ + { + name: "voice_mute", + limit: voiceMuteRateLimit, + wantMsg: "too many mute toggles", + call: func(d VoiceDeps) Result { + return handleVoiceMuteV2(ctx, VoiceMuteCmd{userID: 1, muted: true}, info, d) + }, + }, + { + name: "voice_deafen", + limit: voiceDeafenRateLimit, + wantMsg: "too many deafen toggles", + call: func(d VoiceDeps) Result { + return handleVoiceDeafenV2(ctx, VoiceDeafenCmd{userID: 1, deafened: true}, info, d) + }, + }, + { + name: "voice_e2ee_announce", + limit: voiceE2EERateLimit, + wantMsg: "too many e2ee announcements", + call: func(d VoiceDeps) Result { + return handleVoiceE2EEAnnounceV2(ctx, VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key}, info, d) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := VoiceDeps{Limiter: auth.NewRateLimiter()} + + for i := range tt.limit { + res := tt.call(deps) + ce, ok := res.Error.(ClientError) + if !ok { + t.Fatalf("call %d: expected ClientError, got %v", i, res.Error) + } + if ce.Code != ErrCodeVoiceError { + t.Fatalf("call %d: code = %q, want %q (limit not yet reached)", i, ce.Code, ErrCodeVoiceError) + } + } + + res := tt.call(deps) + ce, ok := res.Error.(ClientError) + if !ok { + t.Fatalf("expected ClientError past the budget, got %v", res.Error) + } + if ce.Code != ErrCodeRateLimited { + t.Errorf("code = %q, want %q", ce.Code, ErrCodeRateLimited) + } + if ce.Message != tt.wantMsg { + t.Errorf("message = %q, want %q", ce.Message, tt.wantMsg) + } + }) + } +} diff --git a/docs/audit-test-coverage-2026-08-19.md b/docs/audit-test-coverage-2026-08-19.md new file mode 100644 index 00000000..7a58fe5c --- /dev/null +++ b/docs/audit-test-coverage-2026-08-19.md @@ -0,0 +1,163 @@ +# OwnCord — Test Audit + +**Date:** 2026-08-19 +**Branch:** fix/test-audit-2026-08-19 (baseline `4ff199e1`, 43 commits of churn since the 08-04 audit) +**Scope:** are the existing tests still correct, and what is missing — all three surfaces. Hybrid depth: mechanical sweep everywhere, deep read in auth / permissions / voice-E2EE / ws hub / rate limits / migrations / Rust proxies+TOFU. Every candidate finding was adversarially verified (refute-by-default) before it was fixed; 40 candidates → 35 confirmed (refuted ones listed in §5 so they are not re-raised), plus 2 manual timezone findings and 12 Stryker-survivor findings. +**Relationship to prior audits:** [audit-test-coverage-2026-07-25.md](audit-test-coverage-2026-07-25.md) (coverage; backlog items 1,2,3,4 closed since) and [audit-2026-08-04-docs-and-coverage.md](audit-2026-08-04-docs-and-coverage.md) (UI flow matrix). Nothing closed there is restated. + +## 1. Method + +- **Fact collection:** `go test -coverpkg=./... ./...` cross-package coverage + zero-coverage function list; `vitest run --coverage`; a changed-since-baseline table (187 changed source files since `4ff199e1`, 82 with no test change) that scoped the finders. +- **Find + verify workflow:** 7 opus finder agents (one per surface slice: server core, server ws/voice, server auth/permissions, client lib, client stores/UI, Rust, e2e/config), each returning structured findings; every candidate then went to an opus refuter with a refute-by-default prompt. 40 candidates → 35 confirmed, 5 refuted (§5). Two timezone-dependent tests found manually during Stryker dry runs (T-24, T-25). +- **Stryker (bonus signal):** mutation testing over 13 risky client modules — score 67.04%, 595 survived / 108 no-coverage. Each module's survivors became one round-2 finding (T-38..T-49), fixed by a second 12-agent wave (opus on dispatcher/e2eeCrypto/ws/identity/livekitE2EE, sonnet otherwise). +- **Proving tests can fail:** every `missing`-kind fix carried an agent-run RED proof (break the source, watch the new test fail, restore byte-identical, watch green). Three re-proved by hand afterwards, one per surface: `TestSetAuthRateScale_ClampsMultiplier` (clamp ×2 → `scaledAuthLimit(9) = 36, want 18`), `validate_server_url_rejects_unsafe_urls` (https guard → `if false` → panic at update_commands.rs:240), `tests/unit/host-validation.test.ts` (IPv6 `&&` → `||` → 4 failed). +- **Gates:** full ci-check — 4 Go build-tag variants, vet, `-race` (all packages), `-tags deadlock ./ws/`, golangci-lint, sqlc + protocol drift verify, vitest, tsc, eslint, prettier, `cargo test`, clippy `-D warnings` — all green at the end of the branch. + +## 2. Findings + +49 findings, all **RESOLVED** (0 declined, 0 open). 14 high / 32 medium / 3 low; 19 stale / 30 missing. T-01..23 + T-26..37 from the verified sweep, T-24/25 manual (timezone), T-38..49 Stryker survivors per module. + +| ID (T-2026-08-19-…) | Sev | Kind | Finding | Status | +|----|-----|------|---------|--------| +| T-01 | HIGH | stale | `Client/tauri-client/src-tauri/src/commands.rs:315` — The two `fingerprint_validation_*` tests never call `store_cert_fingerprint` (or any production function) — they re-implement the validation loop inside the test body and assert… | **RESOLVED** | +| T-02 | HIGH | missing | `Client/tauri-client/src-tauri/src/secret_store.rs:139` — `set_with`'s two read-back-failure arms — the keyring returning a *different* secret (which must purge the foreign entry) and the keyring returning *no* entry (the mock-store bu… | **RESOLVED** | +| T-03 | HIGH | missing | `Client/tauri-client/src-tauri/src/update_commands.rs:120` — `validate_server_url` — the only guard on the updater's server URL, on a path that downloads and executes an installer — has zero tests; the file's test module covers only endpo… | **RESOLVED** | +| T-04 | HIGH | missing | `Client/tauri-client/src/components/ChannelSidebar.ts:128` — The sidebar-layer TOCTOU guard on the E2EE identity re-pin — pin the key captured BEFORE the async fingerprint compute, never a fresh membersStore re-read — is stated as an inva… | **RESOLVED** | +| T-05 | HIGH | missing | `Client/tauri-client/src/lib/ws.ts:126` — The TS mirror of the Rust cert-TOFU store key, normalizeHostForCertCompare, was never updated (and has no test) for the bracketed-IPv6 collapse that the same bughunt commit adde… | **RESOLVED** **+ source bug fixed** | +| T-06 | HIGH | missing | `Server/api/constants.go:29` — setAuthRateScale / scaledAuthLimit — added since the baseline and the multiplier for every per-IP auth rate limit and the login failure/lockout threshold — have no test at all, … | **RESOLVED** | +| T-07 | HIGH | missing | `Server/auth/totp_encrypt.go:140` — DecryptTOTPSecret's real decryption path — hex decode through AES-GCM Open, including the explicitly documented "Fail CLOSED" branch on authentication failure — is never execute… | **RESOLVED** | +| T-08 | HIGH | stale | `Server/db/session_expiry_test.go:84` — TestMigration031_NormalizesLegacyFormats claims to verify migration 031's one-time normalization pass but executes a hand-copied duplicate of the migration's SQL instead of runn… | **RESOLVED** | +| T-09 | HIGH | missing | `Server/migrations/030_attachments_unlink_on_message_delete.sql:36` — Migration 030 is the only migration in the chain that destroys and recreates a table holding user data, and no test ever runs it with rows present, so its data-copy fidelity is … | **RESOLVED** | +| T-10 | HIGH | missing | `Server/ws/handlers_command.go:83` — The entire success path of the plugin chat_command handler — ephemeral reply, the MessageService.CanPost broadcast gate, and the plugin_broadcast fan-out — has zero coverage, so… | **RESOLVED** | +| T-11 | HIGH | missing | `Server/ws/livekit_webhook.go:50` — No test ever feeds the LiveKit webhook endpoint a validly-signed request, so neither the SDK's signature/body-hash verification nor the participant_joined/participant_left dispa… | **RESOLVED** | +| T-12 | HIGH | missing | `Server/ws/livekit_webhook.go:111` — The OC-0018 "detach from the request context" fix landed on both webhook handlers but only the participant_left sibling got a regression test; the participant_joined side is unt… | **RESOLVED** | +| T-13 | HIGH | missing | `Server/ws/voice_controls.go:148` — The permission gate that blocks enabling a camera without USE_VIDEO or a screenshare without SHARE_SCREEN has no test — its refusal branch never executes in the suite. | **RESOLVED** | +| T-14 | HIGH | missing | `Server/ws/voice_moderation.go:385` — No test asserts that voice_mod_move refuses when the TARGET lacks CONNECT_VOICE on the destination channel — the guard that stops a moderator move from placing someone into a ch… | **RESOLVED** | +| T-15 | MEDIUM | missing | `Client/tauri-client/src-tauri/src/credentials.rs:88` — `with_credential_lock`'s stated poison-recovery invariant — a panic inside one credential command must not permanently wedge every later credential operation — has no test, thou… | **RESOLVED** | +| T-16 | MEDIUM | missing | `Client/tauri-client/src-tauri/src/tofu.rs:87` — `CaptureVerifier` — the seam that records the leaf certificate fingerprint for both the ws and http proxies' TOFU decision — has no test, while its sibling `PinnedVerifier`/`Hos… | **RESOLVED** | +| T-17 | MEDIUM | missing | `Client/tauri-client/src/lib/hostValidation.ts:24` — hostValidation.ts — extracted in the last bughunt commit as the single gate for every user-supplied server address — has no test file of its own, and nothing asserts the two gua… | **RESOLVED** | +| T-18 | MEDIUM | missing | `Client/tauri-client/src/lib/rate-limiter.ts:163` — No test ties the client's pre-configured voice limiter to the server budget its own header comment claims to mirror; createVoiceLimiter allows 20 sends/second while the server c… | **RESOLVED** **+ source bug fixed** | +| T-19 | MEDIUM | missing | `Client/tauri-client/src/pages/connect-page/LoginForm.ts:643` — The login form's anti-phishing cap on server-controlled error text — truncate any auth error over 200 characters — is a stated security guard that no test asserts. | **RESOLVED** | +| T-20 | MEDIUM | missing | `Client/tauri-client/src/pages/main-page/SidebarArea.ts:475` — The OC-0174 fix (announcement channels count as text-like for every automatic channel fallback) was applied to five call sites, but only the three in dispatcher.ts got regressio… | **RESOLVED** | +| T-21 | MEDIUM | missing | `Client/tauri-client/src/stores/voice.store.ts:412` — The module-level `pttPollingLive` capability flag and its stated invariant that `resetVoiceStore()` must not clear it are asserted by no test — both accessor bodies are never ex… | **RESOLVED** | +| T-22 | MEDIUM | stale | `Client/tauri-client/tests/browser/smoke.test.ts:3` — The entire tests/browser suite is this one file, which imports no application module and asserts only that the browser environment exists, so it cannot fail on any product chang… | **RESOLVED** | +| T-23 | MEDIUM | stale | `Client/tauri-client/tests/unit/media.test.ts:1173` — The lightbox test named "cleans up document-level listeners on close" contains no assertion whatsoever — it dispatches three events after closing and asserts nothing, so it pass… | **RESOLVED** | +| T-24 | MEDIUM | stale | `Client/tauri-client/tests/unit/renderers.test.ts:551` — `formats full date correctly for bare SQLite timestamp` formats UTC midnight ("2026-03-19 00:00:00" is treated as UTC) in the machine's local zone and asserts the day is 19 — it… | **RESOLVED** | +| T-25 | MEDIUM | stale | `Client/tauri-client/tests/unit/renderers.test.ts:1228` — The formatMessageTimestamp DST tests set process.env.TZ inside beforeEach, which Node only honours in the main thread / a forked process; under a worker-thread pool (Stryker's v… | **RESOLVED** | +| T-26 | MEDIUM | missing | `Server/admin/handlers_channels.go:123` — OC-0158's post-commit-cancellation fix and test were applied to the PATCH and DELETE channel handlers but not to the sibling CREATE handler, which still re-reads on the cancelab… | **RESOLVED** **+ source bug fixed** | +| T-27 | MEDIUM | missing | `Server/api/middleware.go:185` — RequirePermission's fail-closed branch — the 403 taken when the request context carries no *db.Role — has zero coverage, so nothing pins that the server-wide authz chokepoint de… | **RESOLVED** | +| T-28 | MEDIUM | missing | `Server/api/middleware.go:301` — The skip-invalid-entry guard in the X-Forwarded-For right-to-left walk (the BUG-112 anti-spoofing logic that derives every rate-limit and lockout key) is never exercised: no tes… | **RESOLVED** | +| T-29 | MEDIUM | missing | `Server/api/waf.go:468` — The inline WAF engine's four phase-2 request-body attack rules (SQLi 942100, XSS 941100, path traversal 930100, command injection 932100 — all `deny,status:403` under an always-… | **RESOLVED** | +| T-30 | MEDIUM | missing | `Server/auth/totp_encrypt.go:45` — LoadOrGenerateTOTPKey's "read the existing totp.key from disk" branch has zero coverage, so nothing asserts the TOTP encryption key is stable across restarts. | **RESOLVED** | +| T-31 | MEDIUM | missing | `Server/updater/download.go:227` — The updater's tar extraction has a documented O_EXCL anti-TOCTOU guard and three tar-entry filters that no test exercises — the only test hits the happy path into a path that ne… | **RESOLVED** | +| T-32 | MEDIUM | missing | `Server/ws/hub_broadcast.go:230` — Both "fail closed" DB-error branches of the broadcast audience resolver are the only uncovered blocks in channelReadAudience — nothing asserts that an unreadable channel row or … | **RESOLVED** | +| T-33 | MEDIUM | missing | `Server/ws/hub_events.go:106` — No test ever broadcasts through a hub that has an EventPersister attached, so the invariant that the persisted row's seq matches the seq embedded in the wrapped payload — the ba… | **RESOLVED** | +| T-34 | MEDIUM | missing | `Server/ws/voice_join.go:91` — The rate-limit refusal branch is never executed for voice_join, for the shared voice_mute/voice_deafen self-toggle, or for voice_e2ee_announce, even though the sibling handlers'… | **RESOLVED** | +| T-35 | LOW | missing | `Client/tauri-client/src/components/UserProfilePopup.ts:125` — `position()`'s viewport flip-and-clamp — written specifically to fix a card that ran off the bottom of the window — has no test; every one of its four branches is unexercised. | **RESOLVED** | +| T-36 | LOW | missing | `Client/tauri-client/src/lib/e2eeCrypto.ts:293` — The epoch-range reject path added by the OC-0001 room-key epoch fix is the one guard in unwrapRoomKey's v1 header parse that no test reaches, even though its sibling guards (edi… | **RESOLVED** | +| T-37 | LOW | stale | `Client/tauri-client/tests/unit/log-persistence.test.ts:478` — A test for clearPendingPersistedLogs's no-op path contains no assertion at all, so it passes for any implementation that does not hang. | **RESOLVED** | +| T-38 | MEDIUM | stale | `Client/tauri-client/src/lib/credentials.ts:9` — Stryker (2026-08-19, pre-round-1 tree): 13 of 33 mutants in Client/tauri-client/src/lib/credentials.ts survived (score 60.6%) — the unit tests covering this module do not pin th… | **RESOLVED** | +| T-39 | MEDIUM | stale | `Client/tauri-client/src/lib/dispatcher.ts:87` — Stryker (2026-08-19, pre-round-1 tree): 125 of 453 mutants in Client/tauri-client/src/lib/dispatcher.ts survived (score 72.4%) — the unit tests covering this module do not pin t… | **RESOLVED** | +| T-40 | MEDIUM | stale | `Client/tauri-client/src/lib/permissions.ts:107` — Stryker (2026-08-19, pre-round-1 tree): 3 of 54 mutants in Client/tauri-client/src/lib/permissions.ts survived (score 94.4%) — the unit tests covering this module do not pin the… | **RESOLVED** | +| T-41 | MEDIUM | stale | `Client/tauri-client/src/lib/rate-limiter.ts:41` — Stryker (2026-08-19, pre-round-1 tree): 5 of 42 mutants in Client/tauri-client/src/lib/rate-limiter.ts survived (score 88.1%) — the unit tests covering this module do not pin th… | **RESOLVED** | +| T-42 | MEDIUM | stale | `Client/tauri-client/src/lib/hostValidation.ts:25` — Stryker (2026-08-19, pre-round-1 tree): 11 of 35 mutants in Client/tauri-client/src/lib/hostValidation.ts survived (score 68.6%) — the unit tests covering this module do not pin… | **RESOLVED** | +| T-43 | MEDIUM | stale | `Client/tauri-client/src/stores/messages.store.ts:197` — Stryker (2026-08-19, pre-round-1 tree): 52 of 338 mutants in Client/tauri-client/src/stores/messages.store.ts survived (score 84.6%) — the unit tests covering this module do not… | **RESOLVED** | +| T-44 | MEDIUM | stale | `Client/tauri-client/src/lib/e2eeCrypto.ts:19` — Stryker (2026-08-19, pre-round-1 tree): 30 of 99 mutants in Client/tauri-client/src/lib/e2eeCrypto.ts survived (score 69.7%) — the unit tests covering this module do not pin the… | **RESOLVED** | +| T-45 | MEDIUM | stale | `Client/tauri-client/src/lib/ws.ts:8` — Stryker (2026-08-19, pre-round-1 tree): 150 of 352 mutants in Client/tauri-client/src/lib/ws.ts survived (score 57.4%) — the unit tests covering this module do not pin these bra… | **RESOLVED** | +| T-46 | MEDIUM | stale | `Client/tauri-client/src/lib/identity.ts:24` — Stryker (2026-08-19, pre-round-1 tree): 25 of 75 mutants in Client/tauri-client/src/lib/identity.ts survived (score 66.7%) — the unit tests covering this module do not pin these… | **RESOLVED** | +| T-47 | MEDIUM | stale | `Client/tauri-client/src/lib/livekitE2EE.ts:33` — Stryker (2026-08-19, pre-round-1 tree): 234 of 484 mutants in Client/tauri-client/src/lib/livekitE2EE.ts survived (score 51.7%) — the unit tests covering this module do not pin … | **RESOLVED** | +| T-48 | MEDIUM | stale | `Client/tauri-client/src/stores/auth.store.ts:16` — Stryker (2026-08-19, pre-round-1 tree): 6 of 21 mutants in Client/tauri-client/src/stores/auth.store.ts survived (score 71.4%) — the unit tests covering this module do not pin t… | **RESOLVED** | +| T-49 | MEDIUM | stale | `Client/tauri-client/src/stores/voice.store.ts:124` — Stryker (2026-08-19, pre-round-1 tree): 49 of 134 mutants in Client/tauri-client/src/stores/voice.store.ts survived (score 63.4%) — the unit tests covering this module do not pi… | **RESOLVED** | + +## 3. Measured baselines (diff against these next time) + +### Go — cross-package (`go test -coverpkg=./... ./...`) + +| | Before | After | +|---|---|---| +| Total statements | 80.1% | **80.8%** | +| Zero-coverage functions (excl. dbgen/scripts/main) | 62 | **54** | + +Per-package before→after (mean per-function statement coverage): + +| Package | Before | After | +|---|---|---| +| admin | 86.6% | 86.6% | +| api | 84.4% | 84.8% | +| auth | 91.5% | 93.5% | +| config | 80.6% | 80.6% | +| db | 84.1% | 84.2% | +| diskutil | 87.5% | 87.5% | +| invariants | 81.8% | 81.8% | +| logctx | 96.0% | 96.0% | +| permissions | 100.0% | 100.0% | +| plugin | 77.0% | 77.3% | +| service | 91.5% | 91.5% | +| stackutil | 94.1% | 94.1% | +| storage | 89.6% | 89.6% | +| syncutil | n/a | n/a | +| telemetry | 74.4% | 74.4% | +| updater | 87.3% | 87.8% | +| ws | 88.2% | 89.5% | + +Zero-coverage note: both counts come from the identical filter over the before/after profiles (an earlier scratch note said 51 — that used a narrower scope). The −8 is nine functions gaining coverage (`handleGetAuditLog`, ws `ChannelID`/`Payload`, `buildCommandReply`/`buildCommandBroadcast`, hub `Register`/`Unregister`, `maxColdReplayLimit`, `rejectIfRunning`) while `EventPersisterStats` merely moved lines. The remaining 54 are dominated by the root-package CLI (`token_cli.go`, `restart.go Mode`) and similar wiring. + +### Client (`vitest run --coverage`) + +| | Before | After | +|---|---|---| +| Test files | 185 | 185 | +| Tests | 5045 | 5164 | +| Statements | 96.17% | **96.41%** | +| Branches | 92.52% | **92.93%** | +| Functions | 94.70% | **95.02%** | + +### Rust (`cargo test --lib`) + +108 → 114 tests. + +### Stryker (risky client modules, before round 2) + +| Module | Killed | Survived | Score | +|---|---|---|---| +| `src/lib/credentials.ts` | 20 | 13 | 60.6% | +| `src/lib/dispatcher.ts` | 328 | 125 | 72.4% | +| `src/lib/permissions.ts` | 51 | 3 | 94.4% | +| `src/lib/rate-limiter.ts` | 37 | 5 | 88.1% | +| `src/lib/cert-reconnect.ts` | 13 | 0 | 100% | +| `src/lib/hostValidation.ts` | 24 | 11 | 68.6% | +| `src/stores/messages.store.ts` | 286 | 52 | 84.6% | +| `src/lib/e2eeCrypto.ts` | 69 | 30 | 69.7% | +| `src/lib/ws.ts` | 202 | 150 | 57.4% | +| `src/lib/identity.ts` | 50 | 25 | 66.7% | +| `src/lib/livekitE2EE.ts` | 250 | 234 | 51.7% | +| `src/stores/auth.store.ts` | 15 | 6 | 71.4% | +| `src/stores/voice.store.ts` | 85 | 49 | 63.4% | + +Overall: **67.04%**, 595 survived / 108 no-coverage before round 2. + +Round 2 (T-38..49) then killed the actionable survivors per module with strengthened assertions; mutants proven genuinely equivalent (unobservable behaviour) were documented in the fix notes and left. Stryker was not re-run after round 2 (≈40 min a pass); the next audit's run diffs against the table above. + +## 4. Bugs surfaced by the tests + +All non-security; each fixed test-first inside its finding's commit: + +- `Client/tauri-client/src/lib/ws.ts:127` — `normalizeHostForCertCompare` never gained the portless bracketed-IPv6 unwrap its Rust twin `cert_store_key` (`src-tauri/src/tofu.rs:302`) got in the same OC-series fix — cert-pin comparison could mismatch on bracketed IPv6 hosts. +- `Client/tauri-client/src/lib/rate-limiter.ts` — `createVoiceLimiter()` allowed 20 ops/s where the server budget is 2/s; client now matches `Server/ws/voice_broadcast.go`. +- `Server/admin/handlers_channels.go` — `handleCreateChannel` re-read the committed row with the request context, so a caller cancellation landing after commit 500ed the request after the channel was created (OC-0158 sibling). + +Also: `ws.CommandDispatcher` gained a small test seam (`deps.go`/`hub.go`) so command dispatch is unit-testable without a live hub. + +## 5. Refuted candidates (do not re-raise) + +- `Client/tauri-client/src/lib/api.ts:300` — The OC-0161 invariant — a 401 that is a per-call verdict rather than a session verdict must not fire the global onUnauthorized sink — was fixed and tested fo… — refuted: The verifyTotp 401 path is already covered by a test that pins deliberate behavior (tests/unit/api.test.ts:541), and the OC-0161 harm cannot occur there: the credential-deleting/logout logic hangs off authStore.subscribe +- `Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:146` — `createVoiceModerationCallbacks` — the client's four moderator voice commands (voice_mod_mute / voice_mod_deafen / voice_mod_move / voice_mod_kick) and their… — refuted: createVoiceModerationCallbacks is not untested: the mocked E2E spec drives the real factory through the voice context menu and asserts the exact wire type and payload for voice_mod_mute ({channel_id:10,user_id:2,muted:tr +- `Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts:186` — `handleCreateGroupDm` and the `dmChannelFromPayload` mapper it depends on are the only two exported functions in SidebarDmHelpers.ts with no unit test at all… — refuted: handleCreateGroupDm and dmChannelFromPayload are covered indirectly by the mocked E2E social-parity spec, which clicks through the member picker, asserts the POST /api/v1/dms/group body, and then asserts the app switched +- `Client/tauri-client/src/stores/voice.store.ts:508` — The four E2EE peer-verification writers in voice.store.ts (setPeerVerification, clearPeerVerification, setLocalSessionFingerprint, clearPeerVerifications) ar… — refuted: The claim "never executed by any test" is false: tests/e2e/voice-e2ee-verify.spec.ts drives the real app (real livekitE2EE + real voice.store, CI job client-e2e runs `npx playwright test --config=playwright.config.ts`), +- `Client/tauri-client/src-tauri/src/tofu.rs:371` — `mismatch_message`'s doc states the frontend parses `Stored:` out of it and the shape must stay stable, but no Rust test asserts that shape and the TypeScrip… — refuted: Both emitters send `storedFingerprint` as an explicit JSON field (ws_proxy.rs:212, http_proxy.rs:434) and ws.ts prefers it (`raw.storedFingerprint ?? parseStoredFingerprint(raw.message)`), so a change to `mismatch_messag + +## 6. Backlog + +| # | Item | Finding | Sev | +|---|------|---------|-----| +| 1 | Re-run Stryker on the 13 risky modules to measure the round-2 kill rate; chase any remaining non-equivalent survivors (worst pre-round-2: `livekitE2EE.ts` 51.7%, `ws.ts` 57.4%) | T-38..49 follow-up | low | diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index 76e01d39..d421b316 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -3,11 +3,11 @@ "1": "createElement", "2": "testing.T", "3": "livekitSession.ts", - "4": "dispatcher.ts", + "4": "messages.store.ts", "5": "openMigratedMemory", "6": "context.Context", "7": "buildChannelRouter", - "8": "MessageInput.ts", + "8": "content-parser.ts", "9": "attachments.ts", "10": "telemetry.go", "11": "waitRegistered", @@ -15,8 +15,8 @@ "13": "NewAdminAPI", "14": "Fixed", "15": "messages_test.go", - "16": "main.ts", - "17": "net/http.HandlerFunc", + "16": "ConnectPage.ts", + "17": "writeErr", "18": "NewTestClient", "19": "newHandlerHub", "20": "livekitE2EE.ts", @@ -25,21 +25,21 @@ "23": "tofu.rs", "24": "newAuthTestDB", "25": "newMigratedTestDB", - "26": "time.Time", + "26": "EventPersister", "27": "Config", "28": "secret_store.rs", - "29": "User", + "29": "Hub", "30": "database/sql.Result", - "31": "newUploadTestDB", - "32": "MainPage.ts", - "33": "writeJSON", + "31": "net/http.Handler", + "32": "channels.store.ts", + "33": "net/http.HandlerFunc", "34": "newAdminTestDB", "35": "HashToken", - "36": "content-parser.ts", + "36": "MessageList.ts", "37": "ChannelSidebar.ts", "38": "plugin/registry_test.go", - "39": "DB", - "40": "Instance", + "39": "newDeafenRaceDB", + "40": "Registry", "41": "livekit_test.go", "42": "NewChecker", "43": "middleware_test.go", @@ -50,12 +50,12 @@ "48": "livekit_proxy.rs", "49": "native/helpers.ts", "50": "NewRouter", - "51": "net/http.Handler", - "52": "totp_test.go", + "51": "profileCreateToken", + "52": "RateLimiter", "53": "newTestDB", - "54": "createLogger", + "54": "reaction-tooltip.ts", "55": "newServeHub", - "56": "OwnCord — Comprehensive Project Audit", + "56": "3. Security", "57": "permissions_test.go", "58": "seedMemberUser", "59": "postJSONWithToken", @@ -66,66 +66,66 @@ "64": "ProfileManager", "65": "README.md", "66": "devDependencies", - "67": "itoa", + "67": "doRequest", "68": "helpers_test.go", - "69": "ChannelService", + "69": "profiles.ts", "70": "Security Policy", "71": "Role", "72": "channels.sql.go", "73": "Deployment Guide", "74": "livekit_proxy_test.go", - "75": "newEmojiService", + "75": "Emoji", "76": "ws_proxy.rs", "77": "bughunt.js", "78": "openAdminTestDB", - "79": "db/db.go", + "79": "DB", "80": "Tables", "81": "newMentionFixture", "82": "Channel", - "83": "NewWAFMiddlewareCRS", - "84": "E2EEManager", + "83": "newWAFMiddleware", + "84": "LiveKitSession", "85": "storage_test.go", "86": "Migrate", "87": "Hub", "88": "chdirTemp", - "89": "AppearanceTab.ts", + "89": "themes.ts", "90": "updater_test.go", "91": "newTestMessageService", - "92": "textAssetServer", + "92": "newTestUpdater", "93": "Hub", "94": "compilerOptions", - "95": "NewEventRingBuffer", - "96": "HandlerRegistry", + "95": "screenShare.ts", + "96": "deps.go", "97": "emoji_handler_test.go", - "98": "handleCreateEmoji", - "99": "doRequest", - "100": "audioPipeline.ts", - "101": "LoadOrGenerate", + "98": "buildErrorMsg", + "99": "DB", + "100": "noise-suppression.ts", + "101": "AdminActions.ts", "102": "Auth Endpoints", - "103": "MigrateFS", - "104": "newOverrideFixture", + "103": "openMemory", + "104": "PermissionService", "105": "Save", "106": "REST API Reference", - "107": "NewRegistry", - "108": "voice_moderation_test.go", + "107": "media-visibility.ts", + "108": "joinVoice", "109": "ptt.rs", "110": "OwnCord Audit — Documentation Accuracy & UI/UX Test Coverage (2026-08-04)", "111": "scripts", - "112": "ResolveTokenHash", + "112": "checkSourceWith", "113": "Load", - "114": "handleVoiceE2EEOfferV2", - "115": "commands.rs", + "114": "handleVoiceE2EEAnnounceV2", + "115": "NewHandler", "116": "newEmitTestHub", "117": "Plan: Remediate security-hardening review regressions", "118": "verify.go", "119": "newRoleCRUDService", - "120": "Checker", + "120": "VoiceDeps", "121": "EnsureLiveKitBinary", "122": "clientip_test.go", - "123": "AudioElements", - "124": "buildErrorMsg", + "123": "connectionStats.ts", + "124": "buildVoiceLeave", "125": "gif_handler_test.go", - "126": "navigateToMainPage", + "126": "e2e/helpers.ts", "127": "messages.sql.go", "128": "Channel Endpoints", "129": "newSignedTestUpdater", @@ -133,8 +133,8 @@ "131": "Topic", "132": "DB", "133": "users", - "134": "Client", - "135": "identity.ts", + "134": "User", + "135": "update_commands.rs", "136": "Queries", "137": "Queries", "138": "Queries", @@ -148,17 +148,17 @@ "146": "handleSetup", "147": "log/slog.Value", "148": "Updater", - "149": "Credential storage", + "149": "syntax-highlight.ts", "150": "dependencies", "151": "newHarvestVoiceDB", "152": "Config Key Reference", - "153": "newTestPermService", + "153": "seedChannel", "154": "markdown.ts", - "155": "VideoGrid.ts", - "156": "Manifest", + "155": "deep-link.ts", + "156": ".attemptAutoReconnect", "157": "rate-limiter.ts", - "158": "e2e/helpers.ts", - "159": "main.test.ts", + "158": "Error", + "159": "video-grid.test.ts", "160": "testing.M", "161": "newMockDB", "162": "OwnCord", @@ -166,7 +166,7 @@ "164": "buildTauriMockScript", "165": "OwnCord Introspection MCP Server", "166": "Bug-detection improvements — design", - "167": "ptt.ts", + "167": "AuditWriter", "168": "eslint-rules.js", "169": "DB", "170": "OwnCord — Security Review", @@ -174,8 +174,8 @@ "172": "net/http.Request", "173": "ChannelTopic", "174": "UserService", - "175": "Blocked — fix attempted, revert-proof failed", - "176": "handlers_backup.go", + "175": "OwnCord Findings Ledger", + "176": "handleRestoreBackup", "177": "logger.ts", "178": "fallback_crypto.rs", "179": "Direct Messages", @@ -183,7 +183,7 @@ "181": "command.go", "182": "NewRingBuffer", "183": "ws-load.js", - "184": "NewMessageService", + "184": "newDMFixture", "185": "handler", "186": "tauri-client/package.json", "187": "screen-share-tracks.test.ts", @@ -193,28 +193,28 @@ "191": "Role Management", "192": "User Profile & Sessions", "193": "F3 — Voice E2EE identity keys + TOFU (the remaining work)", - "194": "run", - "195": "newWazeroTestRegistry", + "194": "Server/main.go", + "195": "totp_encrypt_test.go", "196": "newUserSvc", "197": "plugins_handler_test.go", "198": "badDirFile", "199": "Contributing", - "200": ".handleFreshConnect", + "200": "github.com/coder/websocket.Conn", "201": "mcp-introspect/package.json", "202": "v1.2.0-alpha.1 — Discord feature parity", "203": "Task Observer — Continuous Skill Discovery & Improvement", - "204": "scanPluginDirectory", - "205": "loadPref", + "204": "handleVoiceE2EEOfferV2", + "205": "AudioPipeline", "206": "1. Channel sidebar", - "207": "Voice, Video & E2EE — target UX", - "208": "Updater", + "207": "Messaging — target UX", + "208": "time.Time", "209": "LiveKitClient", "210": "migrate.go", "211": "Queries", - "212": "Messaging — target UX", - "213": "Registry", + "212": "setupRouter", + "213": ".verify_server_cert", "214": "handleChannelFocusV2", - "215": "handlers_channel_perms_test.go", + "215": "itoa", "216": "knip.json", "217": "RNNoiseProcessor", "218": "DeviceManager", @@ -223,7 +223,7 @@ "221": "Connection & Authentication — target UX", "222": "Settings & Admin — target UX", "223": "buildClientUpdateRouter", - "224": "logPersistence.ts", + "224": "Voice, Video & E2EE — target UX", "225": "newTestRoleService", "226": "event.go", "227": "NewTopicRateLimiter", @@ -237,21 +237,21 @@ "235": "LiveKit Setup Guide", "236": "Voice Signaling", "237": "Quick Start Guide", - "238": "readPump", - "239": "mockTauriFullSessionWithVoice", + "238": "OwnCord — Test Audit", + "239": "newBackupFileDB", "240": "index.mjs", - "241": "countingReadStateStore", + "241": "TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates", "242": "bughunt.harness.mjs", "243": "voice-audio-tab.test.ts", "244": "reactions.sql.go", "245": "Channel Permission Overrides", "246": "Server Stats & User Administration", - "247": "deep-link.ts", - "248": "EventSink", + "247": ".DeleteAccount", + "248": "scaledAuthLimit", "249": "handleChatCommandV2", "250": "slashFS", "251": "Running the bughunt pipeline", - "252": "OverlayManagers.ts", + "252": "MainPage.ts", "253": "reconnectAfterCertAccept", "254": "VoiceTopic", "255": "emoji-voicemod.parity.spec.ts", @@ -262,7 +262,7 @@ "260": "OwnCord — Test-Coverage Audit", "261": "OriginAcceptOptions", "262": "EventRingBuffer", - "263": "IsUniqueConstraintError", + "263": ".UpdateUserProfile", "264": "newTokenTestDB", "265": "scripts", "266": "bughunt-fix.harness.mjs", @@ -273,7 +273,7 @@ "271": "TestMigrate_UpgradeFromMigration019PreservesData", "272": "Queries", "273": "TestChannelVisibility_RESTWSAgreement", - "274": "seed.go", + "274": "OwnCord — Comprehensive Project Audit", "275": "Finish the V2 Dispatch Migration (backlog item 11) — Design", "276": "Port Forwarding Guide", "277": "Chat Messages", @@ -291,12 +291,12 @@ "289": "Member Updates", "290": "genprotocol/main.go", "291": "Hub", - "292": ".finishVoiceLeave", + "292": "FenwickTree", "293": "ChatSendCmd", "294": "Environments, Activation Setup, and Handoff-Doc Mode", - "295": "handlePingV2", + "295": "newBlockService", "296": "capabilities-scope.test.ts", - "297": "savePref", + "297": "handleDiagnosticsConnectivity", "298": "GET /admin/api/updates", "299": "Channel-Visibility Unification (backlog item 3) — Design", "300": "sqlc Adoption (D2) — Progress & Plan", @@ -308,7 +308,7 @@ "306": "openFileDB", "307": "hello plugin", "308": "TestAdminAPI_PatchChannel_ArchiveCleansVoice", - "309": "window-state.ts", + "309": "default_verify_schemes", "310": "Tauri HTTP Capability Narrowing — Design", "311": "protocol_contract_test.go", "312": "ChatCommandCmd", @@ -317,31 +317,31 @@ "315": "VoiceWidgetOptions", "316": "LiveKitProcess", "317": "cert-tofu.spec.ts", - "318": "updater.spec.ts", - "319": "message_reactions_test.go", + "318": "navigateToMainPageReady", + "319": "4. Dependencies & Supply Chain", "320": "tsconfig.build.json", "321": "User Blocks", "322": "PATCH /admin/api/settings", "323": "GET /api/v1/gif/search", "324": "First-Run Setup", "325": "LiveKit Endpoints", - "326": "types.go", + "326": "5. Test Coverage & Quality", "327": "Tailscale Guide (Zero-Config Remote Access)", "328": "LiveKitProcess", - "329": "DB", + "329": "perm_grid_test.go", "330": "ChatEditCmd", "331": "VoiceE2EEOfferCmd", "332": "VoiceModDeafenCmd", "333": "VoiceModMuteCmd", - "334": "TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit", - "335": "New", + "334": "admin-static-channel-perms.test.ts", + "335": "NewDMService", "336": "GET /api/v1/client-update/{target}/{current_version}", "337": "OwnCord Architecture Blueprints", "338": "Voice End-to-End Encryption", "339": "feature_request.md", "340": "volume-menu.test.ts", - "341": "buildMetricsRouter", - "342": "RunningInContainer", + "341": "isAddrInUse", + "342": "MetricsSources", "343": "ChatDeleteCmd", "344": "Permission-Middleware Consolidation (audit finding A-2026-07-16) — Design", "345": "MessageDeletedDMEvent", @@ -390,7 +390,7 @@ "388": "TypingDMEvent", "389": "VoiceStateEvent", "390": "TestDeleteExpiredSessions_SargableFormat", - "391": ".EmitEvents", + "391": "jsdom.d.ts", "392": "stubChannelEvent", "393": "stubUserTargetedEvent", "394": "TestPresenceEvents_InvisibleBlanksCustomStatusForOthers", @@ -413,9 +413,16 @@ "411": "pre-commit", "412": "pre-push", "413": "stubBroadcastAllEvent", + "414": "updater.test.ts", + "415": "1. Architecture", "416": "015_plugins.sql", "417": "tryLoadPluginTOML", "418": "tryLoadPluginTOML", + "419": "6. CI/CD & DevEx", + "420": "7. Observability", + "421": "syscall.SysProcAttr", + "422": "Security Policy", + "423": "erroringMembersStore", "424": "protocol-change/SKILL.md", "425": "strip-appimage-bundled-libs.sh", "426": "build.rs", @@ -441,14 +448,14 @@ "446": "voice-test.sh", "447": "proc_spawner_nix.go", "448": "proc_spawner_win.go", - "449": "RateLimiter", + "449": "Capture", "450": "playwright.config.ts", "451": "playwright.config.admin.ts", "452": "playwright.config.native.ts", "453": "playwright.config.prod.ts", "454": "constants.rs", "455": "vite-env.d.ts", - "456": "smoke.test.ts", + "456": "audio-pipeline-vad-worklet.test.ts", "457": ".addEventListener", "458": "tauri-conf-webview2-args.test.ts", "459": "video-grid-track-muted-css.test.ts", @@ -527,5 +534,19 @@ "532": "TestRegisterNow_ReplacementNeverLosesAConcurrentGlobalBroadcast", "533": "prettier", "534": "@vitest/browser", - "535": "@vitest/coverage-v8" + "535": "@vitest/coverage-v8", + "536": "MockAudioContext", + "537": "D7 — Module map", + "538": "WebSocket / Real-time Engine", + "539": "Audit 2026-07-19 — Maintainer Decisions", + "540": "RunningUnderSupervisor", + "541": "owncord-introspect (MCP dev tool)", + "542": "addrinuse_unix.go", + "543": "addrinuse_windows.go", + "544": "msg-actions-bar-focus-css.test.ts", + "545": "livekit-client", + "546": "hub_wiring_test.go", + "547": "@stryker-mutator/api/core", + "548": "@tauri-apps/api/core", + "549": "@tauri-apps/api/event" } diff --git a/graphify-out/.graphify_labels.json.sig b/graphify-out/.graphify_labels.json.sig index 2a6c771f..3f3af21f 100644 --- a/graphify-out/.graphify_labels.json.sig +++ b/graphify-out/.graphify_labels.json.sig @@ -1 +1 @@ -{"0": "55d4333c099463e8", "1": "3fcb494d84468adc", "2": "5bf559833ed34e3a", "3": "92f68cd273a9b874", "4": "19ca1a25db61d663", "5": "61cf987cf7452c9a", "6": "9738762c13b334a0", "7": "f81d81a13ecadfa8", "8": "13588d006d722cc8", "9": "e93f3dcdaef84531", "10": "c4c406d63c108656", "11": "10201f3ea23ad708", "12": "60cb9cb7fb659b6d", "13": "cbb68f028cfbb21d", "14": "e464617647577a97", "15": "817836e980a3357a", "16": "c61f0361ce12efaa", "17": "44bd031808470d52", "18": "5926dc5a8e1f25f3", "19": "6da31498f5760e90", "20": "231e5a17a2859d6f", "21": "f0f247c2c7337c0e", "22": "6b8f58222c3833a8", "23": "63059476d68e4a0e", "24": "abae34a4daecd786", "25": "4672dca4307707cd", "26": "6fba5a61f5d8b349", "27": "7643bc33c4ad1c2f", "28": "3803c2250ce08426", "29": "275af00e980ff405", "30": "a13577618cf9d5af", "31": "d4bb1a6ccff4a470", "32": "76a783eb5c0d6dd9", "33": "d8f3269550204eec", "34": "69c90c519b4f37df", "35": "e5ec377919bd61cb", "36": "5b39a764583cf0ce", "37": "e70d3918d399ae67", "38": "c497b6f8f332d4ce", "39": "9b837b3d513202cf", "40": "69b6c3d79932b24e", "41": "4362ce47f6b57aa8", "42": "a224a236f84b9b24", "43": "3004faaaa783a4f8", "44": "1e5a7d01130b9eb7", "45": "0b91a56597dff67e", "46": "d2df6de74e0b8c9c", "47": "30acdeea63d4a257", "48": "2a883b5f7e85a353", "49": "6bdf4d070b3aae61", "50": "18a2f089fa4b8676", "51": "f0ba95e21aa0dc11", "52": "6fbd89d3329f8269", "53": "518d8c7abd745ce0", "54": "7d71ecc736a5b320", "55": "8043f13f8493b340", "56": "1d3e3be8c1c5ecfc", "57": "d3f2d7efc055060d", "58": "60822ba74d8e3524", "59": "a6bb6347cb638ae8", "60": "5b96dd71c07d1ec9", "61": "3fd16b351ea067c7", "62": "6b362d223956d0d6", "63": "f2b85adb7d2d07b1", "64": "67b7afb27c1f614d", "65": "2edd8adbe4a3739f", "66": "1fbf914ecae8473b", "67": "8a0c3bfbb1c35dcb", "68": "811aba9b7e859132", "69": "d833fa28ee6fbc92", "70": "4a2f9e11fd8ad084", "71": "190a5d4d7930ba0b", "72": "545bbbd39a248e29", "73": "7e4a40b5c5c1d071", "74": "8b0559bf907640fe", "75": "a2011e254f787940", "76": "19496e7a6ed18688", "77": "2fcf0c737b65d3fb", "78": "319819f61a111e60", "79": "9605b2be347fd6e3", "80": "91d3aac154fbba39", "81": "595b598a5d0c859c", "82": "6643375618a9857b", "83": "057121a264996488", "84": "134e3f6f3827609e", "85": "cc27d34a82baf0cb", "86": "b98b1cf70a6c7386", "87": "f68168880d92608e", "88": "ee9d9ecc0ef77585", "89": "09a7f21251a86b28", "90": "feb99704515dfe79", "91": "0b1fce1d9aef71a6", "92": "5df08bc640341c29", "93": "c07543955830df37", "94": "21de920d64fa7cf5", "95": "69959e9d2e8fd013", "96": "0eec0028096bad6c", "97": "efd51105f845d501", "98": "bc3725a16bd3a726", "99": "bc068222442d552e", "100": "4b7c06611d017e8b", "101": "01d5c2ac668ea3e6", "102": "0ce01a8d103c1ce5", "103": "fc74ebf917288d7c", "104": "24d6e60e30f1ecca", "105": "8fc345f8960bd6ec", "106": "5500263c62849987", "107": "12ee317892b3141d", "108": "4b59811e8fccd96f", "109": "faf160496c49a4af", "110": "7fa5e86fb5b859f7", "111": "5cf7ebaa1ab0f37c", "112": "c9628305b663055c", "113": "60f658c8e3cb0292", "114": "0d215edb303c512e", "115": "2bbbecf4edb799b2", "116": "d20fa1f6db7f9d56", "117": "e28dca951d7ca0a3", "118": "ce7667c6140515eb", "119": "ac77d0122ef1555b", "120": "d403824ed0d83917", "121": "58b4fa416f80b293", "122": "57ec126dcfa926e7", "123": "1dbbd9d83021a4c0", "124": "347659248b05b186", "125": "b4c1a3ea46e3acda", "126": "901d521414ce4d54", "127": "c799ccdc983e40b4", "128": "385b7cd4bd246b4c", "129": "21f72c3b967e4ff5", "130": "c6264f7746fc3977", "131": "e507c1e1a5c0a902", "132": "0c1d9e131250f3a0", "133": "189a87afcf0f3b02", "134": "decd5f6f129f1d9a", "135": "7565980acb241648", "136": "6e4ea9f6236016a6", "137": "c62385c22afafc1f", "138": "bba80df6c941b043", "139": "5bc2ed234ee250fc", "140": "5d1e96037e649140", "141": "92b8387e905bd90a", "142": "94cfcbe868f0c1f8", "143": "0ef764c5f251be42", "144": "996f94557438583f", "145": "6bcb5b6388ea20dd", "146": "6c51e6e013679d1a", "147": "d7ca194d611b730c", "148": "0c1bcff18cd6454e", "149": "801f765642d5458b", "150": "202079945a56ce0a", "151": "355fbfbe7c02a5b1", "152": "7dcd65f1f266a81d", "153": "d3855463a821b69a", "154": "a4f35b8aaeac94b3", "155": "2d98f4b3ba32e1d1", "156": "4bcb08f10ee16efb", "157": "997d402f1879ea0d", "158": "ab0436af885c2d09", "159": "63250123ef2f900a", "160": "0047f55b4c80ff3b", "161": "c79b342c3c88739d", "162": "5349dc2e2ead6b40", "163": "42b1082d08c39b50", "164": "a64556f048a3fa3a", "165": "29cf43922e27fcc7", "166": "17cd6b23530afb97", "167": "a0bed1324d3989ab", "168": "425cb5871b3739fc", "169": "4de1fbcf63bab0b2", "170": "9583c984e370d0d7", "171": "70a44128ddd2a585", "172": "326e64383bc229e7", "173": "c1351003d2eb31ea", "174": "10acddec2c053124", "175": "967bbb1cad5550ae", "176": "a881d34e40ac1a91", "177": "fa42e96ab5f434a4", "178": "8d3980fb2d44c170", "179": "740ee3439e73891d", "180": "86dd0abd68cce7bd", "181": "ab27823e12e1283b", "182": "cfe6225489122f9f", "183": "695cd7213266a959", "184": "3364318ae6fb1865", "185": "a9871babeec7452b", "186": "49d33e1357a9c00e", "187": "b3fb262962922e5d", "188": "375479e75c342a49", "189": "2dbaefd48b31fbc1", "190": "46a836d2f70462de", "191": "c6b01ea3e539e92d", "192": "d1b05590fa813dda", "193": "6a2bad7ce6457339", "194": "7fd14c5da47ac9c2", "195": "c9bdd61260d7d29d", "196": "eac5a8cdd274c1cf", "197": "59a29a27fe2aeb9b", "198": "ee9af2e31bb4d69e", "199": "45b5cdd1c0a3ed42", "200": "94b4265f6ff47772", "201": "7f371359f8cbcae2", "202": "bde48ea01a78a31d", "203": "0e16b67c17622c6f", "204": "671b442d9999c649", "205": "a028ba73f60a203e", "206": "5be335bd678dc0e1", "207": "01fc0d960461d67a", "208": "79618ed6048c329f", "209": "6cf8ae3a4377e8b3", "210": "993a2cc7f78ff5ee", "211": "17ed6623f148ec33", "212": "2688a8e6bd6d4a8b", "213": "965551988bbb62da", "214": "814ba695ab3fe9d4", "215": "d44501def814f35f", "216": "c9502ff1fbec6e44", "217": "efcccc9129fb764a", "218": "0b5b4ceef8f6d1cd", "219": "2cd77561eef54d01", "220": "e75e725824ea9795", "221": "8011beca70a26ddf", "222": "e36690c4c6ea70dc", "223": "cbc3054c23c3e7c5", "224": "5231382f9e3a1b10", "225": "fe91cbfe2aa8d0a3", "226": "cd43b54a96b9b626", "227": "073ebfc0d82ece5d", "228": "3d31ed6ba0035a9b", "229": "34b2126166cd1733", "230": "65b8b45a87700755", "231": "77dc6aacce128d60", "232": "69e3b0ad6e14fa56", "233": "dfcf1f53c2773eb6", "234": "d7151cc443a0d3ae", "235": "2d05c9dc165201e2", "236": "d47bda53fbf25407", "237": "b416fd92acbee855", "238": "9fd98b1ced3799e3", "239": "a607edf903085a68", "240": "0f16e037eb9da7b4", "241": "1143027a021f88b0", "242": "36a425c8aef4d209", "243": "10281c2ef334ecf4", "244": "7b77c2e3209f5c22", "245": "37abd99ab905def1", "246": "5271b4f27e7b6403", "247": "f6a4863320b8c1ac", "248": "8b65988aa3fd8b1c", "249": "999c75ec1d65804f", "250": "c1df22372e374115", "251": "4947e519c0aa830e", "252": "f044b6c0d34ae8f7", "253": "81fd07e37694bb91", "254": "af4768cb283824b6", "255": "49c7a214980abb46", "256": "7294472aa6f83fef", "257": "f298841a49015d5c", "258": "5b30b4eda20a98ee", "259": "4713e1339750ca17", "260": "f291869ba39f0a1c", "261": "1491b8a5363601cd", "262": "90d13fab64f252bc", "263": "cc1e7b533d4da379", "264": "ee9e8568ffd4078c", "265": "d76c76e1145c5388", "266": "00a64e391b99cf8b", "267": "085196be307a22ff", "268": "ad708fbbe273ea95", "269": "5176ddbb64f53f47", "270": "fc4d27cde7ab8041", "271": "a340dfcd2eef6466", "272": "b6ab67e936bbd2e8", "273": "2da1d6a29e2fa105", "274": "a80167531e7353f8", "275": "61c4dcf3f1ab0602", "276": "787ddc45a8e0ec99", "277": "bea1ab731f0016f2", "278": "a09acd94fcd91d09", "279": "ab844ddf2be9507a", "280": "05c52b1b5478604d", "281": "18c2e73219a8171e", "282": "1e74126cfe8be8f6", "283": "f79a7ca288131b40", "284": "987acf92654061b4", "285": "6d758949f980ac59", "286": "ea17edcb394b6a26", "287": "b522c0397de29a55", "288": "55212395aebde232", "289": "61d341e3d6044e8e", "290": "810021694c13c86a", "291": "686d734cb58c0f0d", "292": "3ba63cdc21d55afb", "293": "dcd2c0a709ab925f", "294": "7a86aaf4c3bb953c", "295": "54e16886397a0f05", "296": "8f62c91f05330539", "297": "f8358e8e213b85b6", "298": "9718befeaf5e5906", "299": "3c2ae2c4e7a1949b", "300": "8c5971b6925882d9", "301": "a067f6922115aaf0", "302": "a4603b1fbd5baddb", "303": "01585298c38b36a8", "304": "475f5428cc83ef87", "305": "136daf70e87e02a1", "306": "af2df6004f1e66a0", "307": "056986f3ca294dd3", "308": "a2dcf3915447346e", "309": "762206e727553d79", "310": "f62d0ff4241734e7", "311": "25e9a083b16b3359", "312": "a6e061b71798841a", "313": "584cf83aec10d424", "314": "acb3d709fc667892", "315": "ff6b62985215b71d", "316": "28b9538b6974aef9", "317": "aabbe1539b1ea302", "318": "88da36f056092097", "319": "abc019b2420c28a5", "320": "d43c8e41ad02647b", "321": "2988d6d8f485a46c", "322": "2ea6e398df06e887", "323": "d5e5145b976fe497", "324": "ff359fa92047ec71", "325": "fa0ccab3bbbcb772", "326": "56b04324d7677ccd", "327": "ae70af7c50b68cc1", "328": "6945d99925be4760", "329": "4b7b7ffa54d68fd9", "330": "f5a77f29a2d6d382", "331": "4a291a3cef78d580", "332": "853cf2126f3d3ada", "333": "1b5f723dea319f63", "334": "03d27e3e8323ffca", "335": "bf76b20d585a1edf", "336": "dd4bf71037bdf5e9", "337": "bdf0e713b9cb48eb", "338": "8be778cfe8685fb6", "339": "4543c1aaff9332d7", "340": "455559150f1f41ac", "341": "82ddf091c9b63fbc", "342": "9dd604b2237f7ea5", "343": "21be76d4e02295dc", "344": "97aa1c8e01d7a9c0", "345": "f15493f543e0c1b1", "346": "e7fea49f1afe32dd", "347": "bcb3eb55e616c950", "348": "0965d9688e11641b", "349": "34bdc9bb28b363f4", "350": "89dccdf78f8c8f4f", "351": "9352b4af27abc878", "352": "2c252003e531c9bb", "353": "69e0f04ac9f09daf", "354": "b5bd1df08506feb0", "355": "c2b0575b173483c1", "356": "d26d35a80d122be4", "357": "0ecd5fdfa040950b", "358": "90747e9042f6671d", "359": "14d1ae3b1cff934b", "360": "30b502e616e3e01f", "361": "036ef64c84d005dc", "362": "a17154091b064a55", "363": "9c064fb7bd3825f9", "364": "929f7966f607c2d6", "365": "accfebd3c599e7d0", "366": "434949c322bbb405", "367": "03766f79be04df0e", "368": "f52d7b87fa0e13b7", "369": "87bc1a0079f4f956", "370": "eb6489b237602a3d", "371": "a0585796f9a4646b", "372": "fa8b6c3807ddf216", "373": "6788416604fad624", "374": "5a983f36c68ca3fc", "375": "f24eef4943ea4f25", "376": "efd98c8abf12ad1d", "377": "9a35a514b76efbd1", "378": "9a0d84f44fe4ffc1", "379": "8277fe2d31b3a3f4", "380": "d1c5c1a32b6c4110", "381": "b39493ed39b45bf3", "382": "74af31ee46d6ef60", "383": "d24cfd824810865e", "384": "b357e2723c6a435a", "385": "767ac74dca429121", "386": "8ac01c348ddfdfe5", "387": "a9e4077d96048018", "388": "1fefb64451ff7eaa", "389": "381b13c3a3ba2e23", "390": "d0cdc78e9b7af43f", "391": "ed31860cbb4e96c7", "392": "1532d0b3c0551e59", "393": "06fb128ecf74138b", "394": "c3446b19fa9dba31", "395": "7c07f28b71bd0b3e", "396": "708dd0b18a0a4798", "397": "3241630562395df9", "398": "6a32693f62f4d088", "399": "76864a54ed005737", "400": "b696475a2e95f25f", "401": "5bd0d859725efccb", "402": "a8129cea84bdcfe6", "403": "470ea7aa81f5394b", "404": "e3c70389ad967171", "405": "6db915995bc0b271", "406": "0a3aca6ad397c44b", "407": "3f6f84c96be50d9d", "408": "cc9ee9a966b5e226", "409": "5cae267c36b80958", "410": "6b09b53b2124e9a9", "411": "16c63dcd9e5ed158", "412": "fb8672f03334a2a4", "413": "93fd39edfeaa955b", "416": "98b98cadd83b05c3", "417": "3765a56555254119", "418": "84cc8611b5a317a5", "424": "ec847b053e0fc5cc", "425": "99edf7dd8356014f", "426": "1528a41acca8dc22", "427": "e6b0c5ae9144bfae", "428": "0bf8dbaa131f820a", "429": "b713ce65d240607c", "430": "bb8a02b6078be30b", "431": "b98e2c63125b8b99", "432": "512542ea60d90da9", "433": "b88f507bb73b8651", "434": "92c9d40a819f368c", "435": "2076e1d05932fb62", "436": "d6ec261087f6bc77", "437": "34981a383ae71409", "438": "9d98be4fc7b9da16", "439": "649a9984065fda98", "440": "9ed13a6b1fdd29aa", "441": "4ca798e5f087260d", "442": "0c612ec956a1e9a3", "443": "3a3e18d9ac643f35", "444": "77ec63a8255989aa", "445": "47ce12ec866ee546", "446": "ee2953da63e41f03", "447": "15ae692d06dbdc79", "448": "00e62a56bdf3e98e", "449": "6a0e7adcff0907d7", "450": "d08e08c1bef8ce42", "451": "2388d3b1bcf4afd0", "452": "3eaf0f83b4b5d217", "453": "0602067ecbc34d32", "454": "855a53cf44d537f7", "455": "c076f675fe74d98b", "456": "2f301d70eea30507", "457": "96083d75134d170d", "458": "6b646ba69c312c9e", "459": "c8b3ee00dc822e1f", "460": "5cc6567aacbdccba", "461": "e4685a8dbc331296", "462": "c8cdb11a4360d582", "463": "1bdd1e69dc924512", "464": "b96ad4e2e72ee4e0", "465": "e30952305e11ce35", "466": "b0904ef0f0aaf7d4", "467": "4423f00984596bb5", "468": "b9e2755adb66c626", "469": "5748723725fac7bc", "470": "1aaf664c6ffe5a62", "471": "96a9c1c3b06d45b6", "472": "61e9b8c4d917cbbf", "473": "3dd7b2223b12a178", "474": "4ecc297368533e1d", "475": "2ed3d7513c9e9432", "476": "2feb06e3b9fe8085", "477": "8fcea9acee447ea8", "478": "2018180f7ade5b0e", "479": "b5c49b147ba220ed", "480": "c8ab14ccfb2ceb29", "481": "8a2b334f3892ec42", "482": "d5fe45ae7ec07826", "483": "3ada99807193fa75", "484": "c32d2544c6f24a5d", "485": "4f37fd92e0afd609", "486": "82870a72bd6f6775", "487": "922a3676522aa1db", "488": "0c5f4349a24e692e", "489": "4346fd7b60bbee63", "490": "e651d1e0a6a9d2f7", "491": "d97c392f2988607d", "492": "1cc0949deb48d80b", "493": "f73cc7f508a09a27", "494": "328cc6def8813c2b", "495": "ac2f1b72332c19e0", "496": "b9bc48fd0c5bd742", "497": "7b531173c84842e3", "498": "bac56080d614f4e5", "499": "ef4b31ee0b8691a7", "500": "04a02bc8cec46614", "501": "d26007b612e0d28f", "502": "399136a1201e626a", "503": "34da019da67be529", "504": "6f34b9f536c17cca", "505": "cf10ee822d8aa3b5", "506": "634f6e7943a22ae0", "507": "4930f7207553cdd2", "508": "f84d9c6dd0ae72e4", "509": "d786feb243f024f3", "510": "b08fe51e110c766a", "511": "2d6f4b8ec71816fb", "512": "20baa70a65a36421", "513": "ed98e9a9d974754d", "514": "3374a018515d73e0", "515": "75e25c552169d0b1", "516": "31c658375ca655fb", "517": "6faffa92a422d0e1", "518": "ac654ab9bd4ae870", "519": "b8d1ed53bae3e3dd", "520": "25ba94c1e0b355b4", "521": "9747497c2bde5eba", "522": "7eef6136f6be4db7", "523": "a20171ea17b536bb", "524": "945939db6a08497b", "525": "57a40d480d1a07de", "526": "f5c67e18959e1a96", "527": "8782ecf8e7770bcc", "528": "cbcfc4ab756bcc7e", "529": "5c2a9932d803ae8d", "530": "589eb257be817a5b", "531": "1a46e3c34fd4dfbb", "532": "93a1d044672ad228", "533": "fdb6d627efd3ea42", "534": "49fcd2d6813add0a", "535": "72d68125d7c45efa"} \ No newline at end of file +{"0": "10d02ac7f58a09aa", "1": "4390a12dc49bbd3c", "2": "08ce47eb9ac30323", "3": "40dec5085a1ca982", "4": "fabfcffd1cc1a99e", "5": "de84cedd97d56181", "6": "84b038b29c92422f", "7": "16731578e5f91f5a", "8": "ba8ecb89a45f61aa", "9": "3700d55fb5a6424c", "10": "27ea693b2435810b", "11": "cd03725dadd62284", "12": "62b5847dcacd948f", "13": "4fa68210b84027c3", "14": "3a99e55c1a8c5e99", "15": "0788d9d8e33dc3d7", "16": "acdbfc0b6e742a4a", "17": "fa29dc04abf6ad18", "18": "449f6059e00c34a8", "19": "ee1e7b50ca91f5dc", "20": "98f9c889deca44b8", "21": "20268135d3844d10", "22": "6b8f58222c3833a8", "23": "8380ce92e2f943ee", "24": "32205e04159fc03f", "25": "cb74bcf9458e2c66", "26": "a18ed1205ea22c35", "27": "fdb44d355ff70fab", "28": "fd3ba1d91df8b616", "29": "f7b5c27ea2ccff2e", "30": "59bb9a2533fe4194", "31": "230c619b788aafd1", "32": "7125f1abcf24af8f", "33": "9a63feb93e4aac98", "34": "115b1416b6c06927", "35": "e477e1f3f923ff73", "36": "d5cba17dd0ae8bc6", "37": "95aa4be7a1733e31", "38": "46e742f553598f37", "39": "a99387f9796707fc", "40": "2a30dd68f6194ad8", "41": "d3b9cf95bddc68a1", "42": "7d531a2af6c09b7f", "43": "3e12fb11a5ed9d46", "44": "7d741d30235c8657", "45": "a5d8430e4664ee13", "46": "ef5f72af7001b310", "47": "14b859671e5f45d5", "48": "2a883b5f7e85a353", "49": "6bdf4d070b3aae61", "50": "edadf917344e719d", "51": "72305af506fad397", "52": "acc11463239bd3e6", "53": "518d8c7abd745ce0", "54": "0cbab45d2459ff76", "55": "52c03ae4f4404445", "56": "d08592eb98a96332", "57": "064123b81d806408", "58": "60822ba74d8e3524", "59": "d4015e68559d194b", "60": "5b96dd71c07d1ec9", "61": "8da4d567c24081ec", "62": "83305e37bf7e2ffb", "63": "f2b85adb7d2d07b1", "64": "67b7afb27c1f614d", "65": "23ce778f57af1ea6", "66": "1fbf914ecae8473b", "67": "bda86bc0ab18f670", "68": "811aba9b7e859132", "69": "f13abb7e18d7ce48", "70": "4a2f9e11fd8ad084", "71": "87f486d958a939a2", "72": "545bbbd39a248e29", "73": "7e4a40b5c5c1d071", "74": "8b0559bf907640fe", "75": "636fac55b3cfa103", "76": "48b763021519f2a6", "77": "2fcf0c737b65d3fb", "78": "74200049349848a0", "79": "9f9178cb5f09df7a", "80": "91d3aac154fbba39", "81": "2fa09cafe372ee48", "82": "62a10bb594e1a21c", "83": "d3123d9bb0787158", "84": "fb2a07021d7297fc", "85": "cc27d34a82baf0cb", "86": "88cc864f8a44299d", "87": "88e35d7382a187e8", "88": "08c435bca3bc1af6", "89": "4fbd97280674f17c", "90": "165b591ba4c9511e", "91": "195519720e50c3c3", "92": "3e369913d3232f88", "93": "7f0bf7100c000533", "94": "21de920d64fa7cf5", "95": "886a5ed55308f326", "96": "757eeac4955bdd43", "97": "efd51105f845d501", "98": "1a97ffc45638efac", "99": "653c2741af003aa1", "100": "d02abac0abcc2c33", "101": "ac9fa6c62d449a83", "102": "0ce01a8d103c1ce5", "103": "08b7e55fe2faf069", "104": "7be5bfb2ebec405d", "105": "8fc345f8960bd6ec", "106": "5500263c62849987", "107": "5728bbfc930a4de7", "108": "cc82f5e3263f8b52", "109": "faf160496c49a4af", "110": "7fa5e86fb5b859f7", "111": "5cf7ebaa1ab0f37c", "112": "2bc803d90aa52552", "113": "7ab858a59463838e", "114": "1b3668e066f5eca2", "115": "3d94c956f08e9926", "116": "bcba7b715cfd8d7f", "117": "e28dca951d7ca0a3", "118": "ce7667c6140515eb", "119": "ac77d0122ef1555b", "120": "9d7d78c14ca494f7", "121": "071c3814840ec72b", "122": "56211f0abbbe1337", "123": "64c93c60bc4bb48f", "124": "00a7b2f47a70765d", "125": "b4c1a3ea46e3acda", "126": "d81320ddfc819f0b", "127": "c799ccdc983e40b4", "128": "385b7cd4bd246b4c", "129": "0bf148cef1dbd79f", "130": "b9f9504a399eb7bd", "131": "ea68ec76dd8619f8", "132": "2329e8c5a30a94ca", "133": "189a87afcf0f3b02", "134": "f620406b61f176ed", "135": "8d0b846a5849b267", "136": "6e4ea9f6236016a6", "137": "c62385c22afafc1f", "138": "bba80df6c941b043", "139": "0c106bef901e7836", "140": "603fc7ac275a955b", "141": "92b8387e905bd90a", "142": "2b304474febf47a8", "143": "0ef764c5f251be42", "144": "996f94557438583f", "145": "6bcb5b6388ea20dd", "146": "9d851d313ff05596", "147": "d7ca194d611b730c", "148": "b709ac21fbb8fff8", "149": "683e83e60c3505b6", "150": "202079945a56ce0a", "151": "3cc5a0fe50bd8645", "152": "7dcd65f1f266a81d", "153": "835c692da9348e03", "154": "a4f35b8aaeac94b3", "155": "151decdc3f5702d8", "156": "1c2365293f54002e", "157": "997d402f1879ea0d", "158": "c2c7a8be71ddb82c", "159": "5fc41909360529ec", "160": "0047f55b4c80ff3b", "161": "c79b342c3c88739d", "162": "5349dc2e2ead6b40", "163": "42b1082d08c39b50", "164": "cd2c511a507fee5c", "165": "c6019ae39467b559", "166": "17cd6b23530afb97", "167": "9e2feed38d818a16", "168": "425cb5871b3739fc", "169": "4de1fbcf63bab0b2", "170": "9583c984e370d0d7", "171": "70a44128ddd2a585", "172": "7e90da6e0e149564", "173": "5af7c123b002b2ee", "174": "5399b236d0428259", "175": "432adda824954169", "176": "75981b5d65839ce6", "177": "ec91dd8a177174fb", "178": "8d3980fb2d44c170", "179": "740ee3439e73891d", "180": "86dd0abd68cce7bd", "181": "ab27823e12e1283b", "182": "cfe6225489122f9f", "183": "695cd7213266a959", "184": "0562c3509a5a2873", "185": "a9871babeec7452b", "186": "49d33e1357a9c00e", "187": "b3fb262962922e5d", "188": "375479e75c342a49", "189": "2dbaefd48b31fbc1", "190": "46a836d2f70462de", "191": "c6b01ea3e539e92d", "192": "d1b05590fa813dda", "193": "6a2bad7ce6457339", "194": "3f3177401d40a5d5", "195": "80338e7eabe1b94c", "196": "347db61f12983c8b", "197": "59a29a27fe2aeb9b", "198": "ee9af2e31bb4d69e", "199": "45b5cdd1c0a3ed42", "200": "b19234452753a963", "201": "7f371359f8cbcae2", "202": "c5aa85f5c27ba192", "203": "0e16b67c17622c6f", "204": "50b0db243e43934e", "205": "bd27a688225b6a33", "206": "5be335bd678dc0e1", "207": "fe462dcf8baab556", "208": "65035abb2546cfba", "209": "6cf8ae3a4377e8b3", "210": "993a2cc7f78ff5ee", "211": "17ed6623f148ec33", "212": "fbb4344484a7fbb6", "213": "08ecd11fee448983", "214": "814ba695ab3fe9d4", "215": "67450fb4fccf2edc", "216": "c9502ff1fbec6e44", "217": "efcccc9129fb764a", "218": "31f8ddd06ddf3f6b", "219": "2cd77561eef54d01", "220": "e75e725824ea9795", "221": "8011beca70a26ddf", "222": "e36690c4c6ea70dc", "223": "cbc3054c23c3e7c5", "224": "44b65edd735f755b", "225": "1ca0e4f9e70ba0d8", "226": "be63a5fb74ce6591", "227": "073ebfc0d82ece5d", "228": "3d31ed6ba0035a9b", "229": "34b2126166cd1733", "230": "4d5ee29d62784bce", "231": "2ec66a29728379d7", "232": "69e3b0ad6e14fa56", "233": "dfcf1f53c2773eb6", "234": "d7151cc443a0d3ae", "235": "2d05c9dc165201e2", "236": "d47bda53fbf25407", "237": "b416fd92acbee855", "238": "45e2860c8c0d0d86", "239": "0e5086758666bd0c", "240": "0f16e037eb9da7b4", "241": "bfa1db7e1aad8600", "242": "36a425c8aef4d209", "243": "10281c2ef334ecf4", "244": "7b77c2e3209f5c22", "245": "37abd99ab905def1", "246": "5271b4f27e7b6403", "247": "52abca3569d568bb", "248": "18259707b6496a5d", "249": "5b1a296b990bdd06", "250": "c1df22372e374115", "251": "4947e519c0aa830e", "252": "4616fa2cb3ac06e1", "253": "81fd07e37694bb91", "254": "8959843bbefbe780", "255": "92b3baf8159e81d2", "256": "1bb48df81eb9177a", "257": "f298841a49015d5c", "258": "5b30b4eda20a98ee", "259": "4713e1339750ca17", "260": "ba0167872c22b9d6", "261": "1491b8a5363601cd", "262": "b787ba3da01c1b90", "263": "67d7668d4d0e26cb", "264": "ee9e8568ffd4078c", "265": "d76c76e1145c5388", "266": "00a64e391b99cf8b", "267": "085196be307a22ff", "268": "ad708fbbe273ea95", "269": "918a9a562f01d4b3", "270": "fc4d27cde7ab8041", "271": "a340dfcd2eef6466", "272": "b6ab67e936bbd2e8", "273": "2da1d6a29e2fa105", "274": "02ea03bd94b8d70e", "275": "61c4dcf3f1ab0602", "276": "787ddc45a8e0ec99", "277": "bea1ab731f0016f2", "278": "a09acd94fcd91d09", "279": "08b86ed2f6248fbe", "280": "05c52b1b5478604d", "281": "18c2e73219a8171e", "282": "1e74126cfe8be8f6", "283": "f79a7ca288131b40", "284": "987acf92654061b4", "285": "6d758949f980ac59", "286": "ea17edcb394b6a26", "287": "b522c0397de29a55", "288": "55212395aebde232", "289": "61d341e3d6044e8e", "290": "810021694c13c86a", "291": "686d734cb58c0f0d", "292": "4d1dfde6c1945b9d", "293": "dcd2c0a709ab925f", "294": "7a86aaf4c3bb953c", "295": "ee5414973eed2a25", "296": "8f62c91f05330539", "297": "23392c213d354e7f", "298": "9718befeaf5e5906", "299": "3c2ae2c4e7a1949b", "300": "8c5971b6925882d9", "301": "a067f6922115aaf0", "302": "a4603b1fbd5baddb", "303": "01585298c38b36a8", "304": "475f5428cc83ef87", "305": "136daf70e87e02a1", "306": "af2df6004f1e66a0", "307": "056986f3ca294dd3", "308": "6818f385c306eaa4", "309": "ad1afd0a1335a7b6", "310": "f62d0ff4241734e7", "311": "25e9a083b16b3359", "312": "a6e061b71798841a", "313": "584cf83aec10d424", "314": "acb3d709fc667892", "315": "ff6b62985215b71d", "316": "28b9538b6974aef9", "317": "aabbe1539b1ea302", "318": "3cb4433f63bb8819", "319": "6eb94279bdfa4c8e", "320": "d43c8e41ad02647b", "321": "2988d6d8f485a46c", "322": "2ea6e398df06e887", "323": "d5e5145b976fe497", "324": "ff359fa92047ec71", "325": "fa0ccab3bbbcb772", "326": "4698af944673671b", "327": "b24dc65160a65c31", "328": "6945d99925be4760", "329": "1957e05ff32f608d", "330": "f5a77f29a2d6d382", "331": "4a291a3cef78d580", "332": "853cf2126f3d3ada", "333": "1b5f723dea319f63", "334": "f0746bdf38c1afea", "335": "35c5b06c09ddcf7b", "336": "dd4bf71037bdf5e9", "337": "bdf0e713b9cb48eb", "338": "8be778cfe8685fb6", "339": "4543c1aaff9332d7", "340": "455559150f1f41ac", "341": "ff74a0c0b8496fb3", "342": "6c813dc2c3dec42b", "343": "21be76d4e02295dc", "344": "97aa1c8e01d7a9c0", "345": "f15493f543e0c1b1", "346": "e7fea49f1afe32dd", "347": "bcb3eb55e616c950", "348": "0965d9688e11641b", "349": "34bdc9bb28b363f4", "350": "89dccdf78f8c8f4f", "351": "9352b4af27abc878", "352": "2c252003e531c9bb", "353": "69e0f04ac9f09daf", "354": "b5bd1df08506feb0", "355": "c2b0575b173483c1", "356": "d26d35a80d122be4", "357": "0ecd5fdfa040950b", "358": "90747e9042f6671d", "359": "14d1ae3b1cff934b", "360": "30b502e616e3e01f", "361": "036ef64c84d005dc", "362": "a17154091b064a55", "363": "9c064fb7bd3825f9", "364": "929f7966f607c2d6", "365": "accfebd3c599e7d0", "366": "434949c322bbb405", "367": "03766f79be04df0e", "368": "f52d7b87fa0e13b7", "369": "87bc1a0079f4f956", "370": "eb6489b237602a3d", "371": "a0585796f9a4646b", "372": "fa8b6c3807ddf216", "373": "6788416604fad624", "374": "5a983f36c68ca3fc", "375": "f24eef4943ea4f25", "376": "efd98c8abf12ad1d", "377": "9a35a514b76efbd1", "378": "9a0d84f44fe4ffc1", "379": "8277fe2d31b3a3f4", "380": "d1c5c1a32b6c4110", "381": "b39493ed39b45bf3", "382": "74af31ee46d6ef60", "383": "d24cfd824810865e", "384": "b357e2723c6a435a", "385": "767ac74dca429121", "386": "8ac01c348ddfdfe5", "387": "a9e4077d96048018", "388": "1fefb64451ff7eaa", "389": "381b13c3a3ba2e23", "390": "d0cdc78e9b7af43f", "391": "1558277daf85e870", "392": "1532d0b3c0551e59", "393": "06fb128ecf74138b", "394": "c3446b19fa9dba31", "395": "7c07f28b71bd0b3e", "396": "708dd0b18a0a4798", "397": "3241630562395df9", "398": "6a32693f62f4d088", "399": "76864a54ed005737", "400": "b696475a2e95f25f", "401": "5bd0d859725efccb", "402": "892f87b2e7d2e7be", "403": "470ea7aa81f5394b", "404": "e3c70389ad967171", "405": "6db915995bc0b271", "406": "0a3aca6ad397c44b", "407": "3f6f84c96be50d9d", "408": "cc9ee9a966b5e226", "409": "5cae267c36b80958", "410": "6b09b53b2124e9a9", "411": "16c63dcd9e5ed158", "412": "fb8672f03334a2a4", "413": "93fd39edfeaa955b", "414": "dc99b776b2d3bde9", "415": "155f139761bfda01", "416": "98b98cadd83b05c3", "417": "3765a56555254119", "418": "84cc8611b5a317a5", "419": "a824e2955ce6ec76", "420": "d6992de6b08f4dc2", "421": "f8e524ce16d0fad4", "422": "74f36a13253765e1", "423": "13bb81b4ea9530f6", "424": "ec847b053e0fc5cc", "425": "99edf7dd8356014f", "426": "1528a41acca8dc22", "427": "e6b0c5ae9144bfae", "428": "0bf8dbaa131f820a", "429": "b713ce65d240607c", "430": "bb8a02b6078be30b", "431": "b98e2c63125b8b99", "432": "512542ea60d90da9", "433": "b88f507bb73b8651", "434": "92c9d40a819f368c", "435": "2076e1d05932fb62", "436": "d6ec261087f6bc77", "437": "34981a383ae71409", "438": "9d98be4fc7b9da16", "439": "649a9984065fda98", "440": "9ed13a6b1fdd29aa", "441": "4ca798e5f087260d", "442": "0c612ec956a1e9a3", "443": "3a3e18d9ac643f35", "444": "77ec63a8255989aa", "445": "47ce12ec866ee546", "446": "ee2953da63e41f03", "447": "15ae692d06dbdc79", "448": "00e62a56bdf3e98e", "449": "4c0a8ecc52436e89", "450": "d08e08c1bef8ce42", "451": "2388d3b1bcf4afd0", "452": "3eaf0f83b4b5d217", "453": "0602067ecbc34d32", "454": "855a53cf44d537f7", "455": "c076f675fe74d98b", "456": "c43182f74537ef96", "457": "96083d75134d170d", "458": "6b646ba69c312c9e", "459": "c8b3ee00dc822e1f", "460": "5cc6567aacbdccba", "461": "e4685a8dbc331296", "462": "c8cdb11a4360d582", "463": "1bdd1e69dc924512", "464": "b96ad4e2e72ee4e0", "465": "e30952305e11ce35", "466": "b0904ef0f0aaf7d4", "467": "4423f00984596bb5", "468": "b9e2755adb66c626", "469": "5748723725fac7bc", "470": "1aaf664c6ffe5a62", "471": "96a9c1c3b06d45b6", "472": "61e9b8c4d917cbbf", "473": "3dd7b2223b12a178", "474": "4ecc297368533e1d", "475": "2ed3d7513c9e9432", "476": "2feb06e3b9fe8085", "477": "8fcea9acee447ea8", "478": "2018180f7ade5b0e", "479": "b5c49b147ba220ed", "480": "c8ab14ccfb2ceb29", "481": "8a2b334f3892ec42", "482": "d5fe45ae7ec07826", "483": "3ada99807193fa75", "484": "c32d2544c6f24a5d", "485": "4f37fd92e0afd609", "486": "82870a72bd6f6775", "487": "922a3676522aa1db", "488": "0c5f4349a24e692e", "489": "4346fd7b60bbee63", "490": "e651d1e0a6a9d2f7", "491": "d97c392f2988607d", "492": "1cc0949deb48d80b", "493": "f73cc7f508a09a27", "494": "328cc6def8813c2b", "495": "ac2f1b72332c19e0", "496": "b9bc48fd0c5bd742", "497": "7b531173c84842e3", "498": "bac56080d614f4e5", "499": "ef4b31ee0b8691a7", "500": "04a02bc8cec46614", "501": "d26007b612e0d28f", "502": "399136a1201e626a", "503": "34da019da67be529", "504": "6f34b9f536c17cca", "505": "cf10ee822d8aa3b5", "506": "634f6e7943a22ae0", "507": "4930f7207553cdd2", "508": "f84d9c6dd0ae72e4", "509": "d786feb243f024f3", "510": "b08fe51e110c766a", "511": "2d6f4b8ec71816fb", "512": "20baa70a65a36421", "513": "ed98e9a9d974754d", "514": "3374a018515d73e0", "515": "75e25c552169d0b1", "516": "31c658375ca655fb", "517": "6faffa92a422d0e1", "518": "ac654ab9bd4ae870", "519": "b8d1ed53bae3e3dd", "520": "25ba94c1e0b355b4", "521": "9747497c2bde5eba", "522": "7eef6136f6be4db7", "523": "a20171ea17b536bb", "524": "945939db6a08497b", "525": "57a40d480d1a07de", "526": "f5c67e18959e1a96", "527": "8782ecf8e7770bcc", "528": "cbcfc4ab756bcc7e", "529": "5c2a9932d803ae8d", "530": "589eb257be817a5b", "531": "1a46e3c34fd4dfbb", "532": "93a1d044672ad228", "533": "fdb6d627efd3ea42", "534": "49fcd2d6813add0a", "535": "72d68125d7c45efa", "536": "dddb44bb19569eb2", "537": "c61e20863630c32d", "538": "80f28d09566e7863", "539": "848d72d6be632b3f", "540": "75b620b9607e6d07", "541": "200534e51312a03e", "542": "c60c6111389210c3", "543": "7afbc10971e64d7b", "544": "c81dfbf19c9e877f", "545": "6880eadd7d37de1c", "546": "e4ae735963728e8d", "547": "db4057e429756870", "548": "0a4016518602de5a", "549": "61602d52d6b9c79b"} \ No newline at end of file diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index 506502d7..92b112dd 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,16 +1,16 @@ -# Graph Report - OwnCord (2026-08-16) +# Graph Report - OwnCord (2026-08-20) ## Corpus Check -- 1081 files · ~1,387,726 words +- 1124 files · ~1,482,121 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 11331 nodes · 32666 edges · 529 communities (431 shown, 98 thin omitted) -- Extraction: 86% EXTRACTED · 14% INFERRED · 0% AMBIGUOUS · INFERRED: 4683 edges (avg confidence: 0.8) +- 11770 nodes · 34088 edges · 550 communities (450 shown, 100 thin omitted) +- Extraction: 86% EXTRACTED · 14% INFERRED · 0% AMBIGUOUS · INFERRED: 4900 edges (avg confidence: 0.8) - Token cost: 0 input · 0 output ## Graph Freshness -- Built from commit: `33b7d471` +- Built from commit: `c28341ca` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). @@ -19,11 +19,11 @@ - createElement - testing.T - livekitSession.ts -- dispatcher.ts +- messages.store.ts - openMigratedMemory - context.Context - buildChannelRouter -- MessageInput.ts +- content-parser.ts - attachments.ts - telemetry.go - waitRegistered @@ -31,8 +31,8 @@ - NewAdminAPI - Fixed - messages_test.go -- main.ts -- net/http.HandlerFunc +- ConnectPage.ts +- writeErr - NewTestClient - newHandlerHub - livekitE2EE.ts @@ -41,21 +41,21 @@ - tofu.rs - newAuthTestDB - newMigratedTestDB -- time.Time +- EventPersister - Config - secret_store.rs -- User +- Hub - database/sql.Result -- newUploadTestDB -- MainPage.ts -- writeJSON +- net/http.Handler +- channels.store.ts +- net/http.HandlerFunc - newAdminTestDB - HashToken -- content-parser.ts +- MessageList.ts - ChannelSidebar.ts - plugin/registry_test.go -- DB -- Instance +- newDeafenRaceDB +- Registry - livekit_test.go - NewChecker - middleware_test.go @@ -66,12 +66,12 @@ - livekit_proxy.rs - native/helpers.ts - NewRouter -- net/http.Handler -- totp_test.go +- profileCreateToken +- RateLimiter - newTestDB -- createLogger +- reaction-tooltip.ts - newServeHub -- OwnCord — Comprehensive Project Audit +- 3. Security - permissions_test.go - seedMemberUser - postJSONWithToken @@ -82,66 +82,66 @@ - ProfileManager - README.md - devDependencies -- itoa +- doRequest - helpers_test.go -- ChannelService +- profiles.ts - Security Policy - Role - channels.sql.go - Deployment Guide - livekit_proxy_test.go -- newEmojiService +- Emoji - ws_proxy.rs - bughunt.js - openAdminTestDB -- db/db.go +- DB - Tables - newMentionFixture - Channel -- NewWAFMiddlewareCRS -- E2EEManager +- newWAFMiddleware +- LiveKitSession - storage_test.go - Migrate - Hub - chdirTemp -- AppearanceTab.ts +- themes.ts - updater_test.go - newTestMessageService -- textAssetServer +- newTestUpdater - Hub - compilerOptions -- NewEventRingBuffer -- HandlerRegistry +- screenShare.ts +- deps.go - emoji_handler_test.go -- handleCreateEmoji -- doRequest -- audioPipeline.ts -- LoadOrGenerate +- buildErrorMsg +- DB +- noise-suppression.ts +- AdminActions.ts - Auth Endpoints -- MigrateFS -- newOverrideFixture +- openMemory +- PermissionService - Save - REST API Reference -- NewRegistry -- voice_moderation_test.go +- media-visibility.ts +- joinVoice - ptt.rs - OwnCord Audit — Documentation Accuracy & UI/UX Test Coverage (2026-08-04) - scripts -- ResolveTokenHash +- checkSourceWith - Load -- handleVoiceE2EEOfferV2 -- commands.rs +- handleVoiceE2EEAnnounceV2 +- NewHandler - newEmitTestHub - Plan: Remediate security-hardening review regressions - verify.go - newRoleCRUDService -- Checker +- VoiceDeps - EnsureLiveKitBinary - clientip_test.go -- AudioElements -- buildErrorMsg +- connectionStats.ts +- buildVoiceLeave - gif_handler_test.go -- navigateToMainPage +- e2e/helpers.ts - messages.sql.go - Channel Endpoints - newSignedTestUpdater @@ -149,8 +149,8 @@ - Topic - DB - users -- Client -- identity.ts +- User +- update_commands.rs - Queries - Queries - Queries @@ -164,17 +164,17 @@ - handleSetup - log/slog.Value - Updater -- Credential storage +- syntax-highlight.ts - dependencies - newHarvestVoiceDB - Config Key Reference -- newTestPermService +- seedChannel - markdown.ts -- VideoGrid.ts -- Manifest +- deep-link.ts +- .attemptAutoReconnect - rate-limiter.ts -- e2e/helpers.ts -- main.test.ts +- Error +- video-grid.test.ts - testing.M - newMockDB - OwnCord @@ -182,7 +182,7 @@ - buildTauriMockScript - OwnCord Introspection MCP Server - Bug-detection improvements — design -- ptt.ts +- AuditWriter - eslint-rules.js - DB - OwnCord — Security Review @@ -190,8 +190,8 @@ - net/http.Request - ChannelTopic - UserService -- Blocked — fix attempted, revert-proof failed -- handlers_backup.go +- OwnCord Findings Ledger +- handleRestoreBackup - logger.ts - fallback_crypto.rs - Direct Messages @@ -199,7 +199,7 @@ - command.go - NewRingBuffer - ws-load.js -- NewMessageService +- newDMFixture - handler - tauri-client/package.json - screen-share-tracks.test.ts @@ -209,28 +209,28 @@ - Role Management - User Profile & Sessions - F3 — Voice E2EE identity keys + TOFU (the remaining work) -- run -- newWazeroTestRegistry +- Server/main.go +- totp_encrypt_test.go - newUserSvc - plugins_handler_test.go - badDirFile - Contributing -- .handleFreshConnect +- github.com/coder/websocket.Conn - mcp-introspect/package.json - v1.2.0-alpha.1 — Discord feature parity - Task Observer — Continuous Skill Discovery & Improvement -- scanPluginDirectory -- loadPref +- handleVoiceE2EEOfferV2 +- AudioPipeline - 1. Channel sidebar -- Voice, Video & E2EE — target UX -- Updater +- Messaging — target UX +- time.Time - LiveKitClient - migrate.go - Queries -- Messaging — target UX -- Registry +- setupRouter +- .verify_server_cert - handleChannelFocusV2 -- handlers_channel_perms_test.go +- itoa - knip.json - RNNoiseProcessor - DeviceManager @@ -239,7 +239,7 @@ - Connection & Authentication — target UX - Settings & Admin — target UX - buildClientUpdateRouter -- logPersistence.ts +- Voice, Video & E2EE — target UX - newTestRoleService - event.go - NewTopicRateLimiter @@ -253,21 +253,21 @@ - LiveKit Setup Guide - Voice Signaling - Quick Start Guide -- readPump -- mockTauriFullSessionWithVoice +- OwnCord — Test Audit +- newBackupFileDB - index.mjs -- countingReadStateStore +- TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates - bughunt.harness.mjs - voice-audio-tab.test.ts - reactions.sql.go - Channel Permission Overrides - Server Stats & User Administration -- deep-link.ts -- EventSink +- .DeleteAccount +- scaledAuthLimit - handleChatCommandV2 - slashFS - Running the bughunt pipeline -- OverlayManagers.ts +- MainPage.ts - reconnectAfterCertAccept - VoiceTopic - emoji-voicemod.parity.spec.ts @@ -278,7 +278,7 @@ - OwnCord — Test-Coverage Audit - OriginAcceptOptions - EventRingBuffer -- IsUniqueConstraintError +- .UpdateUserProfile - newTokenTestDB - scripts - bughunt-fix.harness.mjs @@ -289,7 +289,7 @@ - TestMigrate_UpgradeFromMigration019PreservesData - Queries - TestChannelVisibility_RESTWSAgreement -- seed.go +- OwnCord — Comprehensive Project Audit - Finish the V2 Dispatch Migration (backlog item 11) — Design - Port Forwarding Guide - Chat Messages @@ -307,12 +307,12 @@ - Member Updates - genprotocol/main.go - Hub -- .finishVoiceLeave +- FenwickTree - ChatSendCmd - Environments, Activation Setup, and Handoff-Doc Mode -- handlePingV2 +- newBlockService - capabilities-scope.test.ts -- savePref +- handleDiagnosticsConnectivity - GET /admin/api/updates - Channel-Visibility Unification (backlog item 3) — Design - sqlc Adoption (D2) — Progress & Plan @@ -324,7 +324,7 @@ - openFileDB - hello plugin - TestAdminAPI_PatchChannel_ArchiveCleansVoice -- window-state.ts +- default_verify_schemes - Tauri HTTP Capability Narrowing — Design - protocol_contract_test.go - ChatCommandCmd @@ -333,31 +333,31 @@ - VoiceWidgetOptions - LiveKitProcess - cert-tofu.spec.ts -- updater.spec.ts -- message_reactions_test.go +- navigateToMainPageReady +- 4. Dependencies & Supply Chain - tsconfig.build.json - User Blocks - PATCH /admin/api/settings - GET /api/v1/gif/search - First-Run Setup - LiveKit Endpoints -- types.go +- 5. Test Coverage & Quality - Tailscale Guide (Zero-Config Remote Access) - LiveKitProcess -- DB +- perm_grid_test.go - ChatEditCmd - VoiceE2EEOfferCmd - VoiceModDeafenCmd - VoiceModMuteCmd -- TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit -- New +- admin-static-channel-perms.test.ts +- NewDMService - GET /api/v1/client-update/{target}/{current_version} - OwnCord Architecture Blueprints - Voice End-to-End Encryption - feature_request.md - volume-menu.test.ts -- buildMetricsRouter -- RunningInContainer +- isAddrInUse +- MetricsSources - ChatDeleteCmd - Permission-Middleware Consolidation (audit finding A-2026-07-16) — Design - MessageDeletedDMEvent @@ -406,7 +406,7 @@ - TypingDMEvent - VoiceStateEvent - TestDeleteExpiredSessions_SargableFormat -- .EmitEvents +- jsdom.d.ts - stubChannelEvent - stubUserTargetedEvent - TestPresenceEvents_InvisibleBlanksCustomStatusForOthers @@ -429,9 +429,16 @@ - pre-commit - pre-push - stubBroadcastAllEvent +- updater.test.ts +- 1. Architecture - 015_plugins.sql - tryLoadPluginTOML - tryLoadPluginTOML +- 6. CI/CD & DevEx +- 7. Observability +- syscall.SysProcAttr +- Security Policy +- erroringMembersStore - protocol-change/SKILL.md - strip-appimage-bundled-libs.sh - jitsi-rnnoise.d.ts @@ -443,7 +450,8 @@ - 014_events_table.sql - chaos-test.sh - voice-test.sh -- RateLimiter +- Capture +- audio-pipeline-vad-worklet.test.ts - github.com/owncord/server - owncord-client - attachments @@ -455,230 +463,232 @@ - prettier - @vitest/browser - @vitest/coverage-v8 +- MockAudioContext +- D7 — Module map +- WebSocket / Real-time Engine +- Audit 2026-07-19 — Maintainer Decisions +- RunningUnderSupervisor +- owncord-introspect (MCP dev tool) ## God Nodes (most connected - your core abstractions) -1. `waitRegistered()` - 287 edges -2. `DB` - 279 edges -3. `NewTestClientWithUser()` - 268 edges -4. `NewAdminAPI()` - 232 edges -5. `openAdminTestDB()` - 215 edges -6. `doRequest()` - 213 edges +1. `DB` - 310 edges +2. `waitRegistered()` - 290 edges +3. `NewTestClientWithUser()` - 273 edges +4. `NewAdminAPI()` - 239 edges +5. `openAdminTestDB()` - 225 edges +6. `doRequest()` - 219 edges 7. `createElement()` - 208 edges -8. `newTestModService()` - 194 edges -9. `newTestRoleService()` - 192 edges -10. `openMigratedMemory()` - 180 edges +8. `newTestModService()` - 201 edges +9. `newTestRoleService()` - 199 edges +10. `Fixed` - 189 edges ## Surprising Connections (you probably didn't know these) - `buildTotpSection()` --indirect_call--> `render()` [INFERRED] Client/tauri-client/src/components/settings/AccountTab.ts → .superpowers/render-ledger.mjs - `renderMessage()` --indirect_call--> `att()` [INFERRED] Client/tauri-client/src/components/message-list/renderers.ts → Client/tauri-client/tests/unit/attachments-media.test.ts -- `SidebarAreaResult` --references--> `MountableComponent` [EXTRACTED] - Client/tauri-client/src/pages/main-page/SidebarArea.ts → Client/tauri-client/src/lib/safe-render.ts -- `SidebarMemberSectionResult` --references--> `MountableComponent` [EXTRACTED] - Client/tauri-client/src/pages/main-page/SidebarMemberSection.ts → Client/tauri-client/src/lib/safe-render.ts -- `E2EEDeps` --references--> `WsClient` [EXTRACTED] - Client/tauri-client/src/lib/livekitE2EE.ts → Client/tauri-client/src/lib/ws.ts +- `createSidebarDmSection()` --indirect_call--> `dm()` [INFERRED] + Client/tauri-client/src/pages/main-page/SidebarDmSection.ts → Client/tauri-client/tests/unit/read-state.test.ts +- `setupRestartAfterResponse()` --calls--> `tryDirectRestartPending()` [INFERRED] + Server/admin/setup_handler.go → Server/admin/restart.go +- `extract_host_for_cert_store()` --calls--> `cert_store_key()` [INFERRED] + Client/tauri-client/src-tauri/src/update_commands.rs → Client/tauri-client/src-tauri/src/tofu.rs ## Import Cycles +- 3-file cycle: `Client/tauri-client/src/lib/audioElements.ts -> Client/tauri-client/src/lib/livekitSession.ts -> Client/tauri-client/src/lib/roomEventHandlers.ts -> Client/tauri-client/src/lib/audioElements.ts` - 3-file cycle: `Client/tauri-client/src/components/message-list/attachments.ts -> Client/tauri-client/src/components/message-list/media.ts -> Client/tauri-client/src/components/message-list/content-parser.ts -> Client/tauri-client/src/components/message-list/attachments.ts` - 3-file cycle: `Client/tauri-client/src/components/message-list/attachments.ts -> Client/tauri-client/src/components/message-list/media.ts -> Client/tauri-client/src/components/message-list/embeds.ts -> Client/tauri-client/src/components/message-list/attachments.ts` -- 3-file cycle: `Client/tauri-client/src/lib/audioElements.ts -> Client/tauri-client/src/lib/livekitSession.ts -> Client/tauri-client/src/lib/roomEventHandlers.ts -> Client/tauri-client/src/lib/audioElements.ts` - 4-file cycle: `Client/tauri-client/src/components/message-list/attachments.ts -> Client/tauri-client/src/components/message-list/media.ts -> Client/tauri-client/src/components/message-list/content-parser.ts -> Client/tauri-client/src/components/message-list/custom-emoji.ts -> Client/tauri-client/src/components/message-list/attachments.ts` -## Communities (529 total, 98 thin omitted) +## Communities (550 total, 100 thin omitted) ### Community 0 - "members.store.ts" Cohesion: 0.03 -Nodes (72): appendGroup(), assignableRoleNames(), closeActiveMenu(), closeActivePopup(), createMemberItem(), createMemberList(), destroy(), FALLBACK_ASSIGNABLE_ROLES (+64 more) +Nodes (77): appendGroup(), assignableRoleNames(), closeActiveMenu(), closeActivePopup(), createMemberItem(), createMemberList(), destroy(), FALLBACK_ASSIGNABLE_ROLES (+69 more) ### Community 1 - "createElement" Cohesion: 0.02 -Nodes (165): appendBanFlow(), BAN_DURATIONS, ChannelContextMenuOptions, ContextMenuResult, createChannelContextMenu(), createMemberContextMenu(), createMenuItem(), createSeparator() (+157 more) +Nodes (185): appendBanFlow(), buildRow(), CertFirstUseModalOptions, CertMismatchModalOptions, createCertFirstUseModal(), createCertMismatchModal(), createIdentityMismatchModal(), IdentityMismatchModalOptions (+177 more) ### Community 2 - "testing.T" Cohesion: 0.02 -Nodes (166): testing.T, adminPanelSource(), TestAdminPanelEmojiSectionIsWired(), TestAdminPanelEmojiUsesTheMemberAPI(), TestLoginRateLimit_Value(), TestRateLimiterCleanupHorizon_CoversMaxSlowMode(), TestLiveKitHealth_Degraded_NilError(), TestLiveKitHealth_Degraded_WithError() (+158 more) +Nodes (181): testing.T, adminPanelSource(), TestAdminPanelEmojiSectionIsWired(), TestAdminPanelEmojiUsesTheMemberAPI(), TestSlashFS_GlobNormalizes(), TestSlashFS_ResolvesBackslashPath(), setupDiagnosticsRouter(), TestDiagnosticsConnectivity_MemberForbidden() (+173 more) ### Community 3 - "livekitSession.ts" -Cohesion: 0.01 -Nodes (154): VoiceModerationCallbacks, buildVoiceAudioTabInner(), CameraInvalidationRegistrar, CameraRegistrar, createVoiceAudioTab(), MicRegistrar, VoiceAudioTabHandle, createVoiceWidget() (+146 more) +Cohesion: 0.02 +Nodes (144): invalidateReactionUsers(), AudioElements, log, userVolumeKey(), log, SCREENSHARE_TILE_ID_OFFSET, log, DispatcherCleanup (+136 more) -### Community 4 - "dispatcher.ts" -Cohesion: 0.03 -Nodes (150): DispatcherCleanup, enforceModeratorAudioState(), livekitSession(), log, mapDmPayload(), mapDmUser(), wireConnectionStatus(), wireDispatcher() (+142 more) +### Community 4 - "messages.store.ts" +Cohesion: 0.04 +Nodes (88): MessageListComponent, createNsfwGate(), NsfwGateOptions, findChannelById(), acknowledgeNsfw(), clearNsfwAcknowledgements(), isNsfwAcknowledged(), nsfwGateRequired() (+80 more) ### Community 5 - "openMigratedMemory" Cohesion: 0.03 -Nodes (186): setRole(), TestDeleteAccount_AdminAllowedWhenOwnerExists(), TestDeleteAccount_AllowedWhenOtherAdminExists(), TestDeleteAccount_AnonymisesUsername(), TestDeleteAccount_ClearsAvatarAndTOTP(), TestDeleteAccount_ClearsPassword(), TestDeleteAccount_ClearsProfileFields(), TestDeleteAccount_DeletesSessions() (+178 more) +Nodes (188): setRole(), TestDeleteAccount_AdminAllowedWhenOwnerExists(), TestDeleteAccount_AllowedWhenOtherAdminExists(), TestDeleteAccount_AnonymisesUsername(), TestDeleteAccount_ClearsAvatarAndTOTP(), TestDeleteAccount_ClearsPassword(), TestDeleteAccount_ClearsProfileFields(), TestDeleteAccount_DeletesSessions() (+180 more) ### Community 6 - "context.Context" Cohesion: 0.02 -Nodes (59): channelPermissionsResponse, Attachment, AttachmentAccess, channelFields, ChannelUpdate, fakeAuditStore, SessionWithBanStatus, context.Context (+51 more) +Nodes (53): APITokenListItem, Attachment, ChannelUpdate, fakeAuditor, fakeAuditStore, ServerStats, SessionWithBanStatus, context.Context (+45 more) ### Community 7 - "buildChannelRouter" Cohesion: 0.06 -Nodes (138): aroundResponse, offlineBroadcaster, purgeBroadcast, purgeResponseBody, reactionUsersResponse, recordingPurgeBroadcaster, aroundPath(), decodeAround() (+130 more) +Nodes (147): aroundResponse, offlineBroadcaster, purgeBroadcast, purgeResponseBody, reactionUsersResponse, recordingPurgeBroadcaster, net/http/httptest.ResponseRecorder, aroundPath() (+139 more) -### Community 8 - "MessageInput.ts" +### Community 8 - "content-parser.ts" Cohesion: 0.03 -Nodes (82): buildPreview(), byLabel(), createEmojiAutocomplete(), EmojiAutocompleteComponent, EmojiAutocompleteOptions, EmojiSuggestion, filterEmojiSuggestions(), MAX_EMOJI_SUGGESTIONS (+74 more) +Nodes (125): byLabel(), createEmojiAutocomplete(), EmojiAutocompleteComponent, EmojiAutocompleteOptions, EmojiSuggestion, filterEmojiSuggestions(), MAX_EMOJI_SUGGESTIONS, MIN_EMOJI_QUERY (+117 more) ### Community 9 - "attachments.ts" Cohesion: 0.03 -Nodes (107): animateGifsPref, buildDownloadButton(), buildFileMeta(), clearAttachmentCaches(), closeDbAfterTransaction(), createObjectUrl(), downloadFile(), fetchMediaAsObjectUrl() (+99 more) +Nodes (108): createDmProfileSidebar(), DmProfileData, DmProfileSidebarComponent, DmProfileSidebarOptions, legacyNoteKey(), loadNote(), saveNote(), scopedNoteKey() (+100 more) ### Community 10 - "telemetry.go" Cohesion: 0.03 -Nodes (81): go.opentelemetry.io/otel/attribute.KeyValue, go.opentelemetry.io/otel/metric.Float64Gauge, go.opentelemetry.io/otel/metric.Float64Histogram, go.opentelemetry.io/otel/metric.Int64Counter, go.opentelemetry.io/otel/metric.Meter, go.opentelemetry.io/otel/sdk/metric.MeterProvider, go.opentelemetry.io/otel/sdk/trace.TracerProvider, go.opentelemetry.io/otel/trace.Span (+73 more) +Nodes (77): go.opentelemetry.io/otel/attribute.KeyValue, go.opentelemetry.io/otel/metric.Float64Gauge, go.opentelemetry.io/otel/metric.Float64Histogram, go.opentelemetry.io/otel/metric.Int64Counter, go.opentelemetry.io/otel/metric.Meter, go.opentelemetry.io/otel/trace.Span, go.opentelemetry.io/otel/trace.Tracer, Invite (+69 more) ### Community 11 - "waitRegistered" -Cohesion: 0.07 -Nodes (124): TestBuildReady_IncludesCanSend(), TestBuildReady_CarriesChannelFeatureFlags(), TestHandleVoiceCamera_BadPayload(), TestHandleVoiceCamera_NotInVoice2(), TestHandleVoiceDeafen_BadPayload(), TestHandleVoiceDeafen_NotInVoice2(), TestHandleVoiceMute_BadPayload(), TestHandleVoiceMute_NotInVoice2() (+116 more) +Cohesion: 0.05 +Nodes (158): TestBuildReady_IncludesCanSend(), TestBuildReady_CarriesChannelFeatureFlags(), TestBuildDMChannelOpen_NilAvatar(), TestBuildDMChannelOpen_NilRecipient(), TestBuildDMChannelOpen_ValidRecipient(), TestHandleVoiceCamera_BadPayload(), TestHandleVoiceCamera_NotInVoice2(), TestHandleVoiceDeafen_BadPayload() (+150 more) ### Community 12 - "types.ts" Cohesion: 0.02 -Nodes (101): SearchOverlayOptions, ApiClientConfig, createApiClient(), log, OnUnauthorized, SessionInfo, SessionsListResponse, ensureHttpProxy() (+93 more) +Nodes (116): createGifPicker(), GIF_UNAVAILABLE_MESSAGE, GifPickerOptions, MessageInputOptions, SearchOverlayOptions, enableRovingNavigation(), setRovingTabindex(), ApiClientConfig (+108 more) ### Community 13 - "NewAdminAPI" -Cohesion: 0.08 -Nodes (108): TestAdminAPI_AuditLog_InvalidLimitParam(), TestAdminAPI_AuditLog_Pagination(), TestAdminAPI_CheckUpdate_NilUpdater(), TestAdminAPI_CreateChannel_DefaultsTypeToText(), TestAdminAPI_CreateChannel_InvalidBody(), TestAdminAPI_DeleteChannel_InvalidID(), TestAdminAPI_ForceLogout_InvalidID(), TestAdminAPI_ListUsers_CapLargeLimit() (+100 more) +Cohesion: 0.09 +Nodes (99): TestAdminAPI_AuditLog_InvalidLimitParam(), TestAdminAPI_AuditLog_Pagination(), TestAdminAPI_CheckUpdate_NilUpdater(), TestAdminAPI_CreateChannel_DefaultsTypeToText(), TestAdminAPI_CreateChannel_InvalidBody(), TestAdminAPI_DeleteChannel_InvalidID(), TestAdminAPI_ForceLogout_InvalidID(), TestAdminAPI_ListUsers_CapLargeLimit() (+91 more) ### Community 14 - "Fixed" Cohesion: 0.01 -Nodes (140): Fixed, OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it, OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived, OC-0005 — medium — Rotation offers exceed the server rate limit in large channels, permanently starving the same peers, OC-0006 — medium — Both rotation paths call keyProvider.setKey with no session-generation guard, OC-0007 — medium — Reconnect reaches the Secured state without confirming the room key is current, OC-0008 — medium — restoreLocalVoiceState has no internal supersession guard, OC-0009 — low — attemptAutoReconnect's tail has no supersession checkpoints after connected (+132 more) +Nodes (188): Fixed, OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever, OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it, OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch, OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived, OC-0005 — medium — Rotation offers exceed the server rate limit in large channels, permanently starving the same peers, OC-0006 — medium — Both rotation paths call keyProvider.setKey with no session-generation guard, OC-0007 — medium — Reconnect reaches the Secured state without confirming the room key is current (+180 more) ### Community 15 - "messages_test.go" -Cohesion: 0.05 -Nodes (67): buildAuthError(), buildChannelCreate(), buildChannelDelete(), buildChannelUpdate(), buildChatBulkDeleted(), buildChatDeleted(), buildChatEdited(), buildChatSendOK() (+59 more) +Cohesion: 0.06 +Nodes (53): buildAuthError(), buildChannelCreate(), buildChannelUpdate(), buildChatBulkDeleted(), buildChatDeleted(), buildChatEdited(), buildMemberBan(), buildMemberJoin() (+45 more) -### Community 16 - "main.ts" -Cohesion: 0.03 -Nodes (68): createUserUpdateCredentialSaver(), deleteCredential(), getInvoke(), loadCredential(), log, saveCredential(), SavedCredential, jumpToMessage() (+60 more) +### Community 16 - "ConnectPage.ts" +Cohesion: 0.07 +Nodes (27): createUserUpdateCredentialSaver(), deleteCredential(), getInvoke(), loadCredential(), log, saveCredential(), SavedCredential, renderPage() (+19 more) -### Community 17 - "net/http.HandlerFunc" +### Community 17 - "writeErr" Cohesion: 0.09 -Nodes (64): createChannelRequest, createTokenRequest, createTokenResponse, errorResponse, HubBroadcaster, memberUnbanBroadcaster, patchUserRequest, PermissionInvalidator (+56 more) +Nodes (62): createChannelRequest, createTokenRequest, createTokenResponse, errorResponse, HubBroadcaster, PermissionInvalidator, putChannelPermissionRequest, reorderRolesRequest (+54 more) ### Community 18 - "NewTestClient" Cohesion: 0.06 -Nodes (82): NewTestClient(), SetClientLastActivityForTest(), TestChatCommand_MalformedPayload_ReturnsBadRequest(), TestChatCommand_NoRegistry_ReturnsError(), TestChatCommand_RateLimited_ReturnsError(), TestChatCommand_UnknownCommand_ReturnsError(), TestEventSink_Emit_NilSink_NoOp(), awaitMessage() (+74 more) +Nodes (84): NewTestClient(), TestChatCommand_MalformedPayload_ReturnsBadRequest(), TestChatCommand_NoRegistry_ReturnsError(), TestChatCommand_RateLimited_ReturnsError(), TestChatCommand_UnknownCommand_ReturnsError(), TestEventSink_Emit_NilSink_NoOp(), TestHub_SetPluginEventSink_NoOp(), awaitMessage() (+76 more) ### Community 19 - "newHandlerHub" Cohesion: 0.08 -Nodes (90): channelFocusMsg(), denyReadOnChannel(), TestChannelFocus_AdminBypassesDeny(), TestChannelFocus_AllowedByDefault(), TestChannelFocus_DeniedByOverride(), TestChatSend_DeniedWithoutSendMessages(), ClientChannelIDForTest(), NewTestClientWithTokenHash() (+82 more) +Nodes (91): channelFocusMsg(), denyReadOnChannel(), TestChannelFocus_AdminBypassesDeny(), TestChannelFocus_AllowedByDefault(), TestChannelFocus_DeniedByOverride(), TestChatSend_DeniedWithoutSendMessages(), ClientChannelIDForTest(), NewTestClientWithTokenHash() (+83 more) ### Community 20 - "livekitE2EE.ts" -Cohesion: 0.11 -Nodes (28): closeIdentityModal(), openIdentityMismatchModal(), ANNOUNCE_DOMAIN, base64ToUint8(), buildAnnounceMessage(), computeKeyFingerprint(), deriveWrappingKey(), generateECDHKeyPair() (+20 more) +Cohesion: 0.06 +Nodes (55): ANNOUNCE_DOMAIN, base64ToUint8(), buildAnnounceMessage(), computeKeyFingerprint(), computeRawKeyFingerprint(), deriveWrappingKey(), encodeOfferEpoch(), exportIdentityKeyPair() (+47 more) ### Community 21 - "drainChanTimeout" -Cohesion: 0.10 -Nodes (87): drainChanTimeout(), TestWebhook_ParticipantLeft_SurvivesCancelledRequestContext(), captureLogs(), participantIdentityFor(), roomNameFor(), TestWebhook_ParticipantJoined_MalformedInput(), TestWebhook_ParticipantJoined_NilFieldsIgnored(), TestWebhook_ParticipantJoined_RogueParticipantFlagged() (+79 more) +Cohesion: 0.09 +Nodes (93): drainChanTimeout(), TestWebhook_ParticipantLeft_SurvivesCancelledRequestContext(), TestWebhookHandler_SignedParticipantLeftDispatches(), captureLogs(), participantIdentityFor(), roomNameFor(), TestWebhook_ParticipantJoined_MalformedInput(), TestWebhook_ParticipantJoined_NilFieldsIgnored() (+85 more) ### Community 22 - "buildDMRouter" Cohesion: 0.08 Nodes (74): cancelAfterArm, cancelOnLookupStore, evictCall, mockBroadcaster, mockBroadcastMsg, watermarkVoiceBroadcaster, decodeBlockedIDs(), dmPut() (+66 more) ### Community 23 - "tofu.rs" -Cohesion: 0.06 -Nodes (54): CapturedFingerprint, CertificateDer, CaptureVerifier, cert_store_key(), decide(), default_verify_schemes(), evaluate(), extract_host() (+46 more) +Cohesion: 0.11 +Nodes (25): CapturedFingerprint, CaptureVerifier, cert_store_key(), decide(), evaluate(), extract_host(), host_scoped(), host_scoped_default_rejection_propagates() (+17 more) ### Community 24 - "newAuthTestDB" -Cohesion: 0.08 -Nodes (82): recordingAuthBroadcaster, TestDeleteAccount_BroadcastsMemberBan(), TestDeleteAccount_NoBroadcasterOmitted(), buildAuthRouter(), buildAuthRouterWithProxies(), contains(), containsStr(), deleteJSONWithToken() (+74 more) +Cohesion: 0.09 +Nodes (80): buildAuthRouter(), buildAuthRouterWithProxies(), contains(), containsStr(), deleteJSONWithToken(), expiredInviteDB(), newAuthTestDB(), postJSON() (+72 more) ### Community 25 - "newMigratedTestDB" Cohesion: 0.06 -Nodes (71): TestBlockUser_And_IsBlocked(), TestBlockUser_Idempotent(), TestBlockUser_SelfBlockIsSilentlyDropped(), TestIsEitherBlocked(), TestListBlockedUsers(), TestUnblockUser(), TestUnblockUser_NotBlockedIsNoOp(), seedEmojiUploader() (+63 more) +Nodes (72): TestBlockUser_And_IsBlocked(), TestBlockUser_Idempotent(), TestBlockUser_SelfBlockIsSilentlyDropped(), TestIsEitherBlocked(), TestListBlockedUsers(), TestUnblockUser(), TestUnblockUser_NotBlockedIsNoOp(), seedEmojiUploader() (+64 more) -### Community 26 - "time.Time" +### Community 26 - "EventPersister" Cohesion: 0.04 -Nodes (26): touchThrottle, failingLockoutStore, PluginRow, rowScanner, rowsScanner, Event, GetEventsSinceParams, GetEventsSinceRow (+18 more) +Nodes (28): PluginRow, rowScanner, rowsScanner, sync/atomic.Bool, DB, parseSQLiteTime(), scanEventRows(), PersistedEvent (+20 more) ### Community 27 - "Config" -Cohesion: 0.11 -Nodes (22): BackupConfig, DatabaseConfig, EventPersistenceConfig, LoggingConfig, PluginsConfig, SecurityConfig, ServerConfig, UploadConfig (+14 more) +Cohesion: 0.09 +Nodes (27): BackupConfig, DatabaseConfig, EventPersistenceConfig, LoggingConfig, PluginsConfig, SecurityConfig, ServerConfig, UploadConfig (+19 more) ### Community 28 - "secret_store.rs" Cohesion: 0.07 -Nodes (61): credential_lock_serializes_overlapping_commands(), CredentialData, CredentialStoreProbe, delete_credential(), delete_identity_key(), identity_account(), load_credential(), load_identity_key() (+53 more) - -### Community 29 - "User" -Cohesion: 0.04 -Nodes (36): User, TestBuildDMChannelOpen_NilAvatar(), TestBuildDMChannelOpen_NilRecipient(), TestBuildDMChannelOpen_ValidRecipient(), TestQualityBitrate_EmptyFallsBackToMedium(), TestQualityBitrate_KnownPresets(), TestQualityBitrate_UnknownFallsBackToMedium(), TestBuildChatSendOK_ValidJSON() (+28 more) +Nodes (65): credential_lock_serializes_overlapping_commands(), CredentialData, CredentialStoreProbe, delete_credential(), delete_identity_key(), identity_account(), load_credential(), load_identity_key() (+57 more) ### Community 30 - "database/sql.Result" Cohesion: 0.04 -Nodes (34): ApplyVoiceServerDeafenParams, ApplyVoiceServerMuteParams, ClearVoiceServerDeafenParams, ClearVoiceServerMuteParams, CreateAPITokenParams, DeleteOtherSessionsParams, DeleteSessionByIDParams, EnableCameraIfUnderLimitParams (+26 more) +Nodes (30): ApplyVoiceServerDeafenParams, ApplyVoiceServerMuteParams, ClearVoiceServerDeafenParams, ClearVoiceServerMuteParams, CreateAPITokenParams, DeleteOtherSessionsParams, DeleteSessionByIDParams, EnableCameraIfUnderLimitParams (+22 more) -### Community 31 - "newUploadTestDB" -Cohesion: 0.13 -Nodes (64): io.Closer, io.Seeker, buildAvatarRouter(), doAvatarUpload(), TestUploadAvatar_IsReadableByOtherUsersWhileInUse(), TestUploadAvatar_NotMountedWithoutStorage(), TestUploadAvatar_RejectsNonImageAndOversizedDimensions(), TestUploadAvatar_RequiresAuthAndAFile() (+56 more) +### Community 31 - "net/http.Handler" +Cohesion: 0.07 +Nodes (94): TLSResult, crypto/tls.Config, go.opentelemetry.io/otel/sdk/metric.MeterProvider, go.opentelemetry.io/otel/sdk/trace.TracerProvider, io.Closer, io.Seeker, net/http.Handler, buildAvatarRouter() (+86 more) -### Community 32 - "MainPage.ts" +### Community 32 - "channels.store.ts" Cohesion: 0.02 -Nodes (134): closeActiveLightbox(), QuickSwitcherOptions, QuickSwitchProfile, applyConnectionStatus(), createServerBanner(), ServerBannerControl, ToastContainer, ToastType (+126 more) +Nodes (150): QuickSwitcherOptions, QuickSwitchProfile, createVoiceWidget(), formatElapsed(), QUALITY_BARS, QUALITY_COLORS, STATUS_LABELS, navigateToChannel() (+142 more) -### Community 33 - "writeJSON" -Cohesion: 0.09 -Nodes (65): changePasswordRequest, createDMRequest, createGroupDMRequest, createInviteRequest, dmVisibilityMarker, dmVoiceEvictor, inviteResponse, ProfileBroadcaster (+57 more) +### Community 33 - "net/http.HandlerFunc" +Cohesion: 0.07 +Nodes (83): changePasswordRequest, createDMRequest, createGroupDMRequest, createInviteRequest, dmVisibilityMarker, dmVoiceEvictor, EmojiBroadcaster, emojiResponse (+75 more) ### Community 34 - "newAdminTestDB" -Cohesion: 0.05 -Nodes (61): fakeAuditor, slowAuditStore, newAdminTestDB(), TestAdminCreateChannel(), TestAdminCreateChannel_DefaultsNotNSFW(), TestAdminCreateChannel_EmptyOptionals(), TestAdminDeleteChannel(), TestAdminDeleteChannel_NonExistent() (+53 more) +Cohesion: 0.06 +Nodes (60): slowAuditStore, newAdminTestDB(), TestAdminCreateChannel(), TestAdminCreateChannel_DefaultsNotNSFW(), TestAdminCreateChannel_EmptyOptionals(), TestAdminDeleteChannel(), TestAdminDeleteChannel_NonExistent(), TestAdminUpdateChannel() (+52 more) ### Community 35 - "HashToken" -Cohesion: 0.11 -Nodes (48): TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(), voiceJoinWSMsg(), GenerateToken(), HashToken(), TestGenerateToken_HexCharacters(), TestGenerateToken_Length(), TestGenerateToken_MultiDeviceUniqueness(), TestGenerateToken_Uniqueness() (+40 more) +Cohesion: 0.10 +Nodes (52): recordingAuthBroadcaster, TestDeleteAccount_BroadcastsMemberBan(), TestDeleteAccount_NoBroadcasterOmitted(), TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(), voiceJoinWSMsg(), GenerateToken(), HashToken(), TestGenerateToken_HexCharacters() (+44 more) -### Community 36 - "content-parser.ts" -Cohesion: 0.02 -Nodes (128): baseMime(), isAudioMime(), isVideoMime(), setServerHost(), appendBlocks(), appendInline(), buildChannelNode(), buildList() (+120 more) +### Community 36 - "MessageList.ts" +Cohesion: 0.04 +Nodes (52): renderMentions(), CLOCK_TIME_FORMAT, formatFullDate(), formatMessageTimestamp(), formatTime(), FULL_DATE_FORMAT, getUserRole(), GROUP_THRESHOLD_MS (+44 more) ### Community 37 - "ChannelSidebar.ts" -Cohesion: 0.04 -Nodes (89): attachChannelContextMenu(), CHANNEL_MUTE_CHANGED, attachDragHandlers(), DragState, ensureGlobalDragListeners(), listenerOwners, releaseOwner(), retargetDetachedDrag() (+81 more) +Cohesion: 0.03 +Nodes (107): attachChannelContextMenu(), CHANNEL_MUTE_CHANGED, attachDragHandlers(), DragState, ensureGlobalDragListeners(), listenerOwners, releaseOwner(), retargetDetachedDrag() (+99 more) ### Community 38 - "plugin/registry_test.go" -Cohesion: 0.19 -Nodes (27): buildZip(), Registry, newRegistryWithDir(), simpleManifest(), TestRegistry_Activate_AfterClose(), TestRegistry_Activate_WithoutRuntime(), TestRegistry_DisablePlugin_ClearsFlagAndCommands(), TestRegistry_DisablePlugin_UnknownIDIsNoOp() (+19 more) +Cohesion: 0.05 +Nodes (74): Config, foundPlugin, TestEventDeliveryHasNoGuestPath(), TestManifestCommandsValidation(), TestRegisterCommandRequiresManifestDeclaration(), TestStorageKeysIsolatedPerPlugin(), TestStorageRejectsOversizedKeyAndValue(), openPluginTestDB() (+66 more) -### Community 39 - "DB" -Cohesion: 0.06 -Nodes (63): backupFile, roleDeletingInvalidator, AuthBroadcaster, authSuccessResponse, deleteAccountRequest, loginRequest, passwordConfirmationRequest, registerRequest (+55 more) +### Community 39 - "newDeafenRaceDB" +Cohesion: 0.73 +Nodes (5): mustCreateDeafenRaceChannel(), newDeafenRaceDB(), seedDeafenRaceUser(), TestVoiceModDeafen_RollbackFollowsTargetChannelMove(), TestVoiceModDeafen_UndeafenRollbackDoesNotApplyOnUnauthorizedChannel() -### Community 40 - "Instance" -Cohesion: 0.09 -Nodes (14): github.com/tetratelabs/wazero/api.Memory, github.com/tetratelabs/wazero/api.Module, CommandResult, Instance, Registry, Registry, Registry, Manifest (+6 more) +### Community 40 - "Registry" +Cohesion: 0.05 +Nodes (31): matchRecorder, archive/zip.File, archive/zip.Reader, github.com/tetratelabs/wazero/api.Memory, github.com/tetratelabs/wazero/api.Module, sync.Mutex, sync.RWMutex, Broadcaster (+23 more) ### Community 41 - "livekit_test.go" Cohesion: 0.06 -Nodes (52): google.golang.org/protobuf/proto.Message, TestWebhookParseIdentity_Invalid(), TestWebhookParseIdentity_Valid(), TestWebhookParseRoomChannelID_Invalid(), TestWebhookParseRoomChannelID_Valid(), ParseIdentityForTest(), ParseParticipantIdentityForTest(), ParseRoomChannelIDForTest() (+44 more) +Nodes (55): google.golang.org/protobuf/proto.Message, TestWebhookParseIdentity_Invalid(), TestWebhookParseIdentity_Valid(), TestWebhookParseRoomChannelID_Invalid(), TestWebhookParseRoomChannelID_Valid(), ParseIdentityForTest(), ParseParticipantIdentityForTest(), ParseRoomChannelIDForTest() (+47 more) ### Community 42 - "NewChecker" -Cohesion: 0.20 -Nodes (47): NewChecker(), TestHandleChannelFocus_SkipsNoOpReadStateWrite(), NewChannelService(), TestHandlePresenceUpdate_CustomStatusWriteFailureSwallowedAfterStatusCommit(), TestHandleChannelFocus_DMExemptFromArchiveGate(), TestHandleChannelFocus_RefusedInArchivedChannel(), TestHandleTyping_BlockedInDMEmitsNothing(), TestHandleTyping_NoRateLimitKeyForNonexistentChannel() (+39 more) +Cohesion: 0.13 +Nodes (56): NewChecker(), TestHandleChannelFocus_SkipsNoOpReadStateWrite(), NewChannelService(), Store, TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit(), TestHandlePresenceUpdate_CustomStatusWriteFailureKeepsStoredText(), TestHandlePresenceUpdate_CustomStatusWriteFailureSwallowedAfterStatusCommit(), TestHandleChannelFocus_DMExemptFromArchiveGate() (+48 more) ### Community 43 - "middleware_test.go" -Cohesion: 0.09 -Nodes (50): SecurityHeaders(), AdminIPRestrict(), AuthMiddleware(), MaxBodySize(), RateLimitMiddleware(), RequirePermission(), SecurityHeadersWithTLS(), newAPITestDB() (+42 more) +Cohesion: 0.08 +Nodes (57): contextKey, errorResponse, SecurityHeaders(), TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket(), TestRateLimitMiddlewareWithPrefix_SeparatesLiveKitBucket(), AdminIPRestrict(), AuthMiddleware(), MaxBodySize() (+49 more) ### Community 44 - "authStore" -Cohesion: 0.03 -Nodes (86): createDmProfileSidebar(), DmProfileData, DmProfileSidebarComponent, DmProfileSidebarOptions, legacyNoteKey(), loadNote(), saveNote(), scopedNoteKey() (+78 more) +Cohesion: 0.04 +Nodes (78): StatusOption, colorForStatus(), createStatusPicker(), STATUS_DEFS, StatusDef, StatusPickerComponent, StatusPickerOptions, createUserBar() (+70 more) ### Community 45 - "DB" -Cohesion: 0.05 -Nodes (27): APITokenListItem, ChannelUnread, MessageWithUser, ReactionCount, ReactionInfo, ServerStats, UserPublic, context.CancelFunc (+19 more) +Cohesion: 0.07 +Nodes (20): MessageWithUser, ReactionCount, ReactionInfo, UserPublic, ReactionUser, Message, DB, Message (+12 more) ### Community 46 - "testing.F" -Cohesion: 0.08 -Nodes (20): fuzzTestHelper, github.com/livekit/protocol/livekit.WebhookEvent, testing.F, FuzzValidateAvatarURL(), FuzzValidateDisplayName(), FuzzSanitizeUploadFilename(), FuzzValidateUsername(), fuzzOpenMigratedMemory() (+12 more) +Cohesion: 0.06 +Nodes (33): fuzzTestHelper, testing.F, Capability, CommandSpec, Resources, UISpec, UITab, fuzzGIFBytes() (+25 more) ### Community 47 - "Result" -Cohesion: 0.11 -Nodes (46): VoiceState, requirePerm(), Event, BuildCallSignalForTest(), handleCallDeclineV2(), handleCallRingV2(), dmEventOrFallback(), TestDMEventOrFallback() (+38 more) +Cohesion: 0.12 +Nodes (38): Key(), requirePerm(), Event, BuildCallSignalForTest(), TestPingV2_HappyPath_ReturnsPongReply(), TestPingV2_NoEvents(), TestPingV2_RateLimited_ReturnsEmpty(), handleCallDeclineV2() (+30 more) ### Community 48 - "livekit_proxy.rs" Cohesion: 0.06 @@ -689,36 +699,36 @@ Cohesion: 0.09 Nodes (35): CDP_PORT, cleanupUserDataDir(), createUserDataDir(), __dirname, __filename, NativeFixtures, acquirePersistentPage(), CDP_PORT (+27 more) ### Community 50 - "NewRouter" -Cohesion: 0.06 -Nodes (44): clientDiag, diagnosticsResponse, EventPersisterMetrics, healthDeps, healthResponse, infoResponse, livekitHealthResponse, MetricsSources (+36 more) - -### Community 51 - "net/http.Handler" -Cohesion: 0.11 -Nodes (50): identityKeyFailStore, net/http.Handler, net/http/httptest.ResponseRecorder, doRequestRaw(), getWithToken(), TestUpdateProfile_BroadcastCarriesEveryProfileField(), TestUpdateProfile_RejectsBadDisplayName(), TestUpdateProfile_SetsDisplayNameAndAbout() (+42 more) - -### Community 52 - "totp_test.go" Cohesion: 0.08 -Nodes (37): PartialAuthChallenge, pendingTOTPEnrollment, BuildTOTPURI(), generateOpaqueToken(), PartialAuthStore, PendingTOTPStore, UsedTOTPCodeStore, NewPartialAuthStore() (+29 more) +Nodes (40): healthDeps, healthResponse, infoResponse, livekitHealthResponse, TestBodyCapExemptions_RouteEnvelopesReachable(), TestHandleHealth_CanceledRequestDoesNotPoisonCache(), TestHandleHealth_ChecksAreCached(), TestHandleHealth_DegradedReturns503WithReason() (+32 more) + +### Community 51 - "profileCreateToken" +Cohesion: 0.11 +Nodes (49): identityKeyFailStore, getWithToken(), TestUpdateProfile_BroadcastCarriesEveryProfileField(), TestUpdateProfile_RejectsBadDisplayName(), TestUpdateProfile_SetsDisplayNameAndAbout(), TestChangePassword_MalformedBody(), TestChangePassword_MissingNewPassword(), TestChangePassword_MissingOldPassword() (+41 more) + +### Community 52 - "RateLimiter" +Cohesion: 0.04 +Nodes (84): AuthBroadcaster, authSuccessResponse, deleteAccountRequest, loginRequest, passwordConfirmationRequest, registerRequest, totpConfirmationRequest, totpEnableResponse (+76 more) ### Community 53 - "newTestDB" Cohesion: 0.04 Nodes (79): newTestDB(), TestBanUser_Permanent(), TestBanUser_Temporary(), TestCreateInvite_Success(), TestCreateInvite_UnlimitedUses(), TestCreateSession_Success(), TestCreateUser_CaseInsensitiveDuplicate(), TestCreateUser_DuplicateUsername() (+71 more) -### Community 54 - "createLogger" -Cohesion: 0.07 -Nodes (40): attachReactionTooltip(), buildReactionTooltip(), cache, cacheKey(), chipSetFor(), clearReactionUsersCache(), formatReactorNames(), getCachedReactionUsers() (+32 more) +### Community 54 - "reaction-tooltip.ts" +Cohesion: 0.13 +Nodes (20): attachReactionTooltip(), cache, cacheKey(), chipSetFor(), formatReactorNames(), getCachedReactionUsers(), hide(), hoveringChips (+12 more) ### Community 55 - "newServeHub" Cohesion: 0.09 -Nodes (45): ParseChannelIDForTest(), TestBuildReady_IncludesDMVoiceStates(), TestBuildReady_PropagatesDMChannelsError(), TestBuildReady_PropagatesListMembersError(), TestBuildReady_PropagatesUnreadCountsError(), TestBuildReady_IncludesOwnVoiceStateAfterDMClosed(), newServeHub(), ownerRole() (+37 more) +Nodes (47): ParseChannelIDForTest(), dmChannelStatusFor(), TestBuildReady_DMChannelsHidesDisconnectedRecipientStatus(), TestBuildReady_IncludesDMVoiceStates(), TestBuildReady_PropagatesDMChannelsError(), TestBuildReady_PropagatesListMembersError(), TestBuildReady_PropagatesUnreadCountsError(), TestBuildReady_IncludesOwnVoiceStateAfterDMClosed() (+39 more) -### Community 56 - "OwnCord — Comprehensive Project Audit" -Cohesion: 0.04 -Nodes (48): 1. Architecture, 3. Security, 4. Dependencies & Supply Chain, 5. Test Coverage & Quality, 6. CI/CD & DevEx, 7. Observability, 8. Plugin System Governance, 9. Prioritized Top-10 Action List (+40 more) +### Community 56 - "3. Security" +Cohesion: 0.20 +Nodes (10): 3. Security, Authentication & Authorization, Input Validation, Observations (not blocking), Overall Posture: **GOOD** (no critical issues in core app security), Rate Limiting, Secrets & Configuration, SQL Injection (+2 more) ### Community 57 - "permissions_test.go" -Cohesion: 0.06 -Nodes (54): overrideMatrixBits(), permGridBits(), TestAdminPanelOverrideMatrixCoversChannelScopedBits(), TestAdminPanelOverrideMatrixHasSingleDefinedBits(), TestAdminPanelPermGridCoversEveryPermissionBit(), TestAdminPanelPermGridHasNoDuplicateOrCompositeBits(), EffectiveChannelPerms(), EffectivePerms() (+46 more) +Cohesion: 0.09 +Nodes (39): EffectivePerms(), HasAdmin(), HasAnyPerm(), HasPerm(), HasServerPerm(), TestAdminPerimeter_Membership(), TestEffectivePerms_AllowAddsPermission(), TestEffectivePerms_AllowAndDenyTogether() (+31 more) ### Community 58 - "seedMemberUser" Cohesion: 0.17 @@ -726,51 +736,51 @@ Nodes (47): callMsg(), seedGroupDM(), TestCallDecline_ForwardsToOtherParticipant ### Community 59 - "postJSONWithToken" Cohesion: 0.12 -Nodes (46): postJSONWithToken(), buildCombinedRouter(), TestCombinedRouter_ProfileAndInvites(), TestCreateInvite_MalformedJSON(), TestCreateInvite_WithExpiration(), TestEnableTOTP_AlreadyEnabled(), TestListInvites_MemberForbidden(), TestRevokeInvite_AlreadyRevoked() (+38 more) +Nodes (47): postJSONWithToken(), buildCombinedRouter(), TestCombinedRouter_ProfileAndInvites(), TestCreateInvite_MalformedJSON(), TestCreateInvite_WithExpiration(), TestEnableTOTP_AlreadyEnabled(), TestListInvites_MemberForbidden(), TestRevokeInvite_AlreadyRevoked() (+39 more) ### Community 60 - "http_proxy.rs" Cohesion: 0.08 Nodes (39): A, B, copy_with_deadline(), copy_with_deadline_reclaims_a_stalled_connection(), handle_connection(), HttpProxyState, ProxyEntry, remove_if_port_matches_removes_only_matching_entry() (+31 more) ### Community 61 - "newVoiceTestDB" -Cohesion: 0.15 -Nodes (44): TestVoice_CountActiveCameras_SomeCameras(), TestVoice_CountActiveCameras_Zero(), TestVoice_EnableCameraIfUnderLimit_AtLimit(), TestVoice_EnableCameraIfUnderLimit_Success(), TestVoice_GetAllVoiceStates_MultipleChannels(), TestVoice_JoinVoiceChannelIfCapacity_AtLimit(), TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(), TestVoice_JoinVoiceChannelIfCapacity_UnderLimit() (+36 more) +Cohesion: 0.13 +Nodes (48): TestVoice_CountActiveCameras_SomeCameras(), TestVoice_CountActiveCameras_Zero(), TestVoice_EnableCameraIfUnderLimit_AtLimit(), TestVoice_EnableCameraIfUnderLimit_Success(), TestVoice_GetAllVoiceStates_MultipleChannels(), TestVoice_JoinVoiceChannelIfCapacity_AtLimit(), TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(), TestVoice_JoinVoiceChannelIfCapacity_UnderLimit() (+40 more) ### Community 62 - "messages.go" -Cohesion: 0.05 -Nodes (49): userUpdateSpy, encoding/json.RawMessage, parseCallChannelID(), BuildDMChannelOpenInfoForTest(), buildChatMessage(), buildDMChannelOpen(), buildDMChannelOpenFor(), buildUserUpdate() (+41 more) +Cohesion: 0.04 +Nodes (62): userUpdateSpy, BuildDMChannelOpenInfoForTest(), buildChannelCreateFor(), buildChannelDelete(), buildChatMessage(), buildChatSendOK(), buildDMChannelOpen(), buildDMChannelOpenFor() (+54 more) ### Community 63 - "dbgen/models.go" Cohesion: 0.05 Nodes (27): Attachment, AuditLog, Channel, ChannelOverride, ChannelUserOverride, DmOpenState, DmParticipant, Emoji (+19 more) ### Community 65 - "README.md" -Cohesion: 0.09 -Nodes (17): Client Architecture (Tauri), D7 — Module map, Key mechanisms, Quality tooling, D2 — Package map, D3 — REST request lifecycle, Server Architecture, D1 — System context and trust boundaries (+9 more) +Cohesion: 0.11 +Nodes (9): D2 — Package map, D3 — REST request lifecycle, Server Architecture, D1 — System context and trust boundaries, D8 — Deployment topology, System Overview, D6 — Voice join + E2EE key exchange, Voice and End-to-End Encryption (+1 more) ### Community 66 - "devDependencies" Cohesion: 0.06 Nodes (35): devDependencies, eslint, @eslint/js, fast-check, jsdom, knip, oxlint, @playwright/test (+27 more) -### Community 67 - "itoa" -Cohesion: 0.11 -Nodes (37): itoa(), TestDeleteChannelPermission_RefusesEqualOrHigherRole(), TestPutChannelPermission_ModeratorCannotEscalate(), TestPutChannelPermission_RefusesEqualOrHigherRole(), seedOverrideTarget(), TestChannelUserPermission_NonAdminForbidden(), TestDeleteChannelUserPermission_ClearsOverride(), TestPutChannelUserPermission_AdministratorCanGrantAnyBit() (+29 more) +### Community 67 - "doRequest" +Cohesion: 0.10 +Nodes (38): doRequest(), TestPutChannelUserPermission_CannotTargetHigherRankedUser(), TestAdminAPI_PatchUser_RefusedRoleChangeDoesNotLeaveBanCommitted(), TestAdminAPI_LogStreamTicketFlow_APIToken(), createRoleUser(), newModeratorHandler(), TestAuditAndSettings_BitHoldersAllowed(), TestAuditAndSettings_ModeratorForbidden() (+30 more) ### Community 68 - "helpers_test.go" Cohesion: 0.09 Nodes (39): ExtractBearerToken(), IsEffectivelyBanned(), IsSessionExpired(), TestExtractBearerToken_BearerCaseInsensitive(), TestExtractBearerToken_BearerWithNoToken(), TestExtractBearerToken_EmptyHeaderValue(), TestExtractBearerToken_MissingHeader(), TestExtractBearerToken_MultipleSpaces() (+31 more) -### Community 69 - "ChannelService" -Cohesion: 0.10 -Nodes (12): ReactionUser, channelRefs(), ChannelService, Store, permOverrides(), MessageService, Store, requireChannelWritable() (+4 more) +### Community 69 - "profiles.ts" +Cohesion: 0.06 +Nodes (30): ensureHttpProxy(), log, pending, stopHttpProxy(), CreateProfileData, createProfileManager(), createTauriBackend(), FetchFn (+22 more) ### Community 70 - "Security Policy" Cohesion: 0.14 Nodes (14): Account Deletion, Audit Logging, Client Security Hardening, Credential Storage, Input Validation, Known Limitations, Reporting Vulnerabilities, Search and Rate Limiting (+6 more) ### Community 71 - "Role" -Cohesion: 0.08 -Nodes (21): sync.RWMutex, Role, Store, ModerationService, Store, NewModerationService(), PermissionService, Store (+13 more) +Cohesion: 0.11 +Nodes (17): sync/atomic.Int64, Role, Name(), TestMentionEveryone_BitIsFreeAndNamed(), TestName_KnownAndUnknownBits(), ModerationService, RoleInput, RoleService (+9 more) ### Community 72 - "channels.sql.go" Cohesion: 0.08 @@ -784,45 +794,41 @@ Nodes (39): Admin Backup Endpoint, Auto-Update, Background Maintenance, Backup S Cohesion: 0.08 Nodes (45): net/url.URL, LiveKitHealthHandlerForTest(), copyWS(), isOriginAllowed(), isWebSocketUpgrade(), NewLiveKitProxy(), proxyWebSocket(), TestIsOriginAllowed_CaseInsensitive() (+37 more) -### Community 75 - "newEmojiService" -Cohesion: 0.10 -Nodes (28): recordingEmojiBroadcaster, Emoji, EmojiImageURL(), Store, NewEmojiService(), NormalizeShortcode(), newEmojiService(), TestEmojiCreate_DuplicateShortcodeIsConflict() (+20 more) +### Community 75 - "Emoji" +Cohesion: 0.19 +Nodes (8): recordingEmojiBroadcaster, Emoji, FuzzValidateShortcode(), NormalizeShortcode(), TestValidateShortcode_Accepts(), TestValidateShortcode_Rejects(), ValidateShortcode(), EmojiService ### Community 76 - "ws_proxy.rs" -Cohesion: 0.12 -Nodes (25): AtomicU64, accept_cert_fingerprint(), clear_sender_if_current(), disconnect_closes_the_outbound_channel(), disconnect_invalidates_an_in_flight_connect_attempt(), emit_cert_tofu(), emit_ws_state(), is_valid_cert_fingerprint() (+17 more) +Cohesion: 0.06 +Nodes (41): AtomicU64, get_cert_fingerprint(), get_identity_pin(), get_settings(), identity_pin_key(), is_settings_key_allowed(), log_cmd_err(), open_devtools() (+33 more) ### Community 77 - "bughunt.js" Cohesion: 0.06 Nodes (31): ARGS, BUGCLASS_LENSES, buildAdaptiveLenses(), churnFiles, cleanStreak, clusterOf(), confirmedAll, confirmedSorted (+23 more) ### Community 78 - "openAdminTestDB" -Cohesion: 0.11 -Nodes (34): TestNewHandler_APIRoutesMounted(), TestNewHandler_AuthProtectedRoute(), TestNewHandler_ReturnsNonNilHandler(), TestNewHandler_ServesStaticRoot(), TestNewHandler_SetsCSPOnRoot(), TestNewHandler_WithUpdater(), TestOwnerOnlyMiddleware_AdminDenied(), TestOwnerOnlyMiddleware_MemberDenied() (+26 more) +Cohesion: 0.10 +Nodes (43): openAdminTestDB(), TestAdminAPI_PatchRole_NoRenameNoMemberUpdate(), TestAdminAPI_PatchRole_RenameBroadcastsMemberUpdate(), createUserWithRole(), decodeRole(), doRequestRaw(), newRolesHandler(), TestAdminAPI_CreateRole_DuplicateNameIsBadRequest() (+35 more) -### Community 79 - "db/db.go" -Cohesion: 0.07 -Nodes (26): dbtx, DBTX, database/sql.DB, database/sql.Row, database/sql.Rows, database/sql.Stmt, database/sql.Tx, database/sql.TxOptions (+18 more) +### Community 79 - "DB" +Cohesion: 0.06 +Nodes (46): roleDeletingInvalidator, dbtx, DBTX, database/sql.DB, database/sql.Row, database/sql.Rows, database/sql.Stmt, Queries (+38 more) ### Community 80 - "Tables" Cohesion: 0.05 Nodes (37): Admin perimeter, api_tokens, attachments, audit_log, Bit Map, channel_overrides, channel_user_overrides, channels (+29 more) ### Community 81 - "newMentionFixture" -Cohesion: 0.15 -Nodes (34): parseMentionTokens(), MessageService, mentionCount(), newMentionFixture(), sendAs(), TestChannelFocus_ClearsMentionCount(), TestEditMessage_EveryoneGateApplies(), TestEditMessage_ReplacesMentionsWithoutRecounting() (+26 more) +Cohesion: 0.13 +Nodes (36): FuzzParseMentionTokens(), parseMentionTokens(), MessageService, mentionCount(), newMentionFixture(), sendAs(), TestChannelFocus_ClearsMentionCount(), TestEditMessage_EveryoneGateApplies() (+28 more) ### Community 82 - "Channel" -Cohesion: 0.07 -Nodes (10): memberUpdateCall, mockHub, mockHubWB, restartCall, unbanMockHub, Channel, buildChannelCreateFor(), channelPayloadFrom() (+2 more) +Cohesion: 0.04 +Nodes (31): memberUpdateCall, mockHub, mockHubWB, restartCall, context.CancelFunc, DB, Channel, ChannelOverride (+23 more) -### Community 83 - "NewWAFMiddlewareCRS" -Cohesion: 0.09 -Nodes (33): matchRecorder, coraza.WAF, github.com/corazawaf/coraza/v3/types.Interruption, github.com/corazawaf/coraza/v3/types.MatchedRule, github.com/corazawaf/coraza/v3/types.Transaction, captureSlog(), TestNewCRSWAF_LoadsCoreRuleSet(), TestNormalizeCRSMode() (+25 more) - -### Community 84 - "E2EEManager" -Cohesion: 0.13 -Nodes (4): generateRoomKey(), E2EEManager, clearPeerVerification(), clearPeerVerifications() +### Community 83 - "newWAFMiddleware" +Cohesion: 0.10 +Nodes (39): coraza.WAF, github.com/corazawaf/coraza/v3/types.Interruption, github.com/corazawaf/coraza/v3/types.MatchedRule, github.com/corazawaf/coraza/v3/types.Transaction, captureSlog(), TestNewCRSWAF_LoadsCoreRuleSet(), TestNormalizeCRSMode(), TestWAFMiddleware_CRSBlockMode_AllowsBenignJSONRequest() (+31 more) ### Community 85 - "storage_test.go" Cohesion: 0.07 @@ -830,79 +836,79 @@ Nodes (46): TestSave_FilesystemFailureIsErrIO(), New(), newTestStorage(), TestDe ### Community 86 - "Migrate" Cohesion: 0.13 -Nodes (31): failReadFS, TestNewRouterRefusesToStartWithMalformedTOTPKey(), Migrate(), openMemory(), TestBegin(), TestCloseIdempotent(), TestExec(), TestForeignKeysEnabled() (+23 more) +Nodes (24): failReadFS, Migrate(), TestBegin(), TestCloseIdempotent(), TestExec(), TestForeignKeysEnabled(), TestMigrateCreatesAllTables(), TestMigrateCreatesFTSTable() (+16 more) ### Community 87 - "Hub" -Cohesion: 0.05 -Nodes (25): AuditStore, pendingAudit, sync/atomic.Bool, sync/atomic.Pointer, sync/atomic.Uint64, sync.Once, AuditWriter, DB (+17 more) +Cohesion: 0.09 +Nodes (8): sync/atomic.Pointer, Client, LiveKitProcess, Hub, TopicRateLimiter, broadcastMsg, clientEvent, pendingPresence ### Community 88 - "chdirTemp" -Cohesion: 0.11 -Nodes (31): TestOwnerOnlyMiddleware_OwnerAllowed(), backdate(), listBackupFiles(), mustSetSetting(), TestMaintainBackups_RetentionNeverDeletesNewest(), TestMaintainBackups_ScheduleAndRetention(), CaptureSetupLimiter(), SetBackupBaseDir() (+23 more) +Cohesion: 0.10 +Nodes (38): CaptureSetupLimiter(), CurrentRestartState(), ForceRestartState(), ResetRestartState(), SetApplyRestartDelay(), SetSetupLimiterReapTiming(), StubCloseError(), StubCopyBackup() (+30 more) -### Community 89 - "AppearanceTab.ts" -Cohesion: 0.17 -Nodes (21): buildAppearanceTab(), getDefaultAccent(), hexToRgb(), applyTheme(), applyStoredAppearance(), syncOsMotionListener(), applyThemeByName(), BUILT_IN_THEMES (+13 more) +### Community 89 - "themes.ts" +Cohesion: 0.18 +Nodes (18): applyTheme(), ThemeName, THEMES, applyStoredAppearance(), syncOsMotionListener(), applyThemeByName(), BUILT_IN_THEMES, deleteCustomTheme() (+10 more) ### Community 90 - "updater_test.go" -Cohesion: 0.07 -Nodes (52): TestCheckForUpdate_ErrorCaching(), TestCheckForUpdate_IncludesAssetsList(), TestDownloadFile_NoTokenToExternalHost(), TestDownloadFile_SendsTokenToGitHub(), TestFetchTextAsset_Error(), TestFetchTextAsset_Success(), TestFindClientAssets_ByTarget(), TestFindClientAssets_NilCache() (+44 more) +Cohesion: 0.09 +Nodes (38): archive/tar.Header, TestDownloadFile_NoTokenToExternalHost(), TestDownloadFile_SendsTokenToGitHub(), TestFetchTextAsset_Error(), TestFetchTextAsset_Success(), TestFindClientAssets_ByTarget(), TestFindClientAssets_NilCache(), TestFindClientAssets_NoMatchingAssets() (+30 more) ### Community 91 - "newTestMessageService" Cohesion: 0.12 -Nodes (31): seedAroundHistory(), TestGetMessagesAround_ClampsLimit(), TestGetMessagesAround_DeletedCentreIsNotFound(), TestGetMessagesAround_EdgesReportNoMore(), TestGetMessagesAround_ExactFitReportsNoMore(), TestGetMessagesAround_MessageFromAnotherChannelIsNotFound(), TestGetMessagesAround_RejectsBadIDs(), TestGetMessagesAround_SplitsTheLimitAroundTheCentre() (+23 more) +Nodes (30): seedAroundHistory(), TestGetMessagesAround_ClampsLimit(), TestGetMessagesAround_DeletedCentreIsNotFound(), TestGetMessagesAround_DMNonParticipantIsNotFound(), TestGetMessagesAround_EdgesReportNoMore(), TestGetMessagesAround_ExactFitReportsNoMore(), TestGetMessagesAround_MessageFromAnotherChannelIsNotFound(), TestGetMessagesAround_RejectsBadIDs() (+22 more) -### Community 92 - "textAssetServer" -Cohesion: 0.22 -Nodes (10): net/http/httptest.Server, dialAndAuthWS(), TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(), TestCheckForUpdateCancelledCallerDoesNotPoisonCache(), TestFetchTextAssetCachedCancelledCallerDoesNotPoisonCache(), TestFetchTextAssetCachedCachesFailures(), TestFetchTextAssetCachedCoalescesConcurrentMisses(), TestFetchTextAssetCachedEvictsExpiredKeys() (+2 more) +### Community 92 - "newTestUpdater" +Cohesion: 0.08 +Nodes (32): net/http/httptest.Server, dialAndAuthWS(), TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(), TestCheckForUpdateCancelledCallerDoesNotPoisonCache(), TestFetchTextAssetCachedCancelledCallerDoesNotPoisonCache(), TestCheckForUpdate_ErrorCaching(), TestCheckForUpdate_IncludesAssetsList(), serverDownloadAssetName() (+24 more) ### Community 93 - "Hub" -Cohesion: 0.08 -Nodes (10): TestExtractEventType(), TestExtractEventTypeLengthCap(), Hub, extractEventType(), wrapWithSeq(), buildPresenceMsg(), buildRolesUpdate(), buildServerRestartMsg() (+2 more) +Cohesion: 0.07 +Nodes (6): TestExtractEventType(), TestExtractEventTypeLengthCap(), Hub, extractEventType(), Hub, wrapWithSeq() ### Community 94 - "compilerOptions" Cohesion: 0.06 Nodes (32): compilerOptions, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, lib, module, moduleResolution, noEmit (+24 more) -### Community 95 - "NewEventRingBuffer" -Cohesion: 0.20 -Nodes (18): NewEventRingBuffer(), TestConcurrent_PushAndEventsSince(), TestEventsSince_AfterSeqEqualsOldest_ReturnsNil(), TestEventsSince_AfterSeqZero_ReturnsBehavior(), TestEventsSince_AfterSpecificSeq(), TestEventsSince_AheadOfNewestSeq(), TestEventsSince_AtLatestSeq(), TestEventsSince_CapacityBoundaries() (+10 more) +### Community 95 - "screenShare.ts" +Cohesion: 0.11 +Nodes (33): attachDiagnosticListeners(), bumpGeneration(), CAMERA_PRESETS, CAMERA_PUBLISH_BITRATES, CameraTrackState, disableCamera(), disableScreenshare(), enableCamera() (+25 more) -### Community 96 - "HandlerRegistry" -Cohesion: 0.13 -Nodes (20): registerCallHandlers(), registerPingHandler(), registerPresenceHandlers(), reactionV2Handler(), registerReactionHandlers(), NewHandlerRegistry(), fullV2Registry(), TestAllV2Types_SmokeDispatch() (+12 more) +### Community 96 - "deps.go" +Cohesion: 0.09 +Nodes (30): MessageService, Store, hasPerm(), registerCallHandlers(), registerChatHandlers(), registerPingHandler(), registerPresenceHandlers(), reactionV2Handler() (+22 more) ### Community 97 - "emoji_handler_test.go" Cohesion: 0.17 Nodes (32): emojiHarness, emojiSeedUser(), gifBytes(), jpegBytes(), newEmojiHarness(), pngBytes(), TestBroadcastEmojiSet_SurvivesCanceledRequestContext(), TestEmojiDelete_BadIDIs400() (+24 more) -### Community 98 - "handleCreateEmoji" -Cohesion: 0.10 -Nodes (34): EmojiBroadcaster, emojiResponse, FileStore, uploadResponse, net/http.Client, broadcastEmojiSet(), chi.Router, handleCreateEmoji() (+26 more) - -### Community 99 - "doRequest" -Cohesion: 0.17 -Nodes (25): doRequest(), TestAdminAPI_PatchRole_NoRenameNoMemberUpdate(), TestAdminAPI_PatchRole_RenameBroadcastsMemberUpdate(), createUserWithRole(), decodeRole(), newRolesHandler(), TestAdminAPI_CreateRole_DuplicateNameIsBadRequest(), TestAdminAPI_CreateRole_OK() (+17 more) - -### Community 100 - "audioPipeline.ts" -Cohesion: 0.10 -Nodes (11): log, createRNNoiseProcessor(), createScriptProcessorPipeline(), loadRNNoise(), log, ProcessingPipeline, RNNoiseModule, { mockLoadPref, mockSavePref } (+3 more) - -### Community 101 - "LoadOrGenerate" +### Community 98 - "buildErrorMsg" Cohesion: 0.16 -Nodes (25): TLSResult, crypto/tls.Config, fileExists(), GenerateSelfSigned(), loadACME(), loadCertPair(), LoadOrGenerate(), loadOrGenerateSelfSigned() (+17 more) +Nodes (12): encoding/json.RawMessage, VoiceState, parseCallChannelID(), Client, Hub, buildErrorMsg(), buildVoiceState(), parseChannelID() (+4 more) + +### Community 99 - "DB" +Cohesion: 0.11 +Nodes (9): channelPermissionsResponse, channelFields, channelFromFields(), Channel, ChannelOverride, ChannelRoleOverride, ChannelUserOverride, DB (+1 more) + +### Community 100 - "noise-suppression.ts" +Cohesion: 0.18 +Nodes (7): createRNNoiseProcessor(), createScriptProcessorPipeline(), loadRNNoise(), log, ProcessingPipeline, RNNoiseModule, { mockLoadPref, mockSavePref } + +### Community 101 - "AdminActions.ts" +Cohesion: 0.13 +Nodes (16): BAN_DURATIONS, ChannelContextMenuOptions, ContextMenuResult, createChannelContextMenu(), createMemberContextMenu(), createMenuItem(), createSeparator(), MemberContextMenuOptions (+8 more) ### Community 102 - "Auth Endpoints" Cohesion: 0.07 Nodes (30): Auth Endpoints, DELETE /api/v1/auth/account, DELETE /api/v1/users/me/totp, Errors, Errors, Errors, Errors, GET /api/v1/auth/me (+22 more) -### Community 103 - "MigrateFS" -Cohesion: 0.20 -Nodes (29): testing/fstest.MapFS, MigrateFS(), countVersions(), hasVersion(), simpleFS(), tableExists(), TestMigrate_AllMigrationsRecorded(), TestMigrate_AppliedAtIsISO8601() (+21 more) +### Community 103 - "openMemory" +Cohesion: 0.18 +Nodes (36): testing/fstest.MapFS, openMemory(), TestMigrateFSInvalidSQL(), TestMigrateFSReadFileError(), TestMigrateFSSkipsNonSQL(), MigrateFS(), countVersions(), hasVersion() (+28 more) -### Community 104 - "newOverrideFixture" -Cohesion: 0.62 -Nodes (6): newOverrideFixture(), seedChannelUserOverride(), TestListVisibleChannels_PerUserOverrideSplitsRoleMates(), TestPermissionService_AppliesUserOverrideLayer(), TestPermissionService_InvalidateUserPicksUpNewOverride(), visibleIDs() +### Community 104 - "PermissionService" +Cohesion: 0.16 +Nodes (12): newOverrideFixture(), seedChannelUserOverride(), TestListVisibleChannels_PerUserOverrideSplitsRoleMates(), TestPermissionService_AppliesUserOverrideLayer(), TestPermissionService_InvalidateUserPicksUpNewOverride(), visibleIDs(), Store, NewEmojiService() (+4 more) ### Community 105 - "Save" Cohesion: 0.18 @@ -912,13 +918,13 @@ Nodes (20): bytesProvider, go.yaml.in/yaml/v3.Node, validateYAML(), applyPatch() Cohesion: 0.07 Nodes (29): Admin API Authorization, Audit Log, Authentication, Channel Management (admin), Diagnostics, Error Codes, GET /admin/api/audit-log, GET /admin/api/me (+21 more) -### Community 107 - "NewRegistry" -Cohesion: 0.18 -Nodes (16): TestEmptyAllowlistDeniesEveryHost(), TestManifestCommandsValidation(), TestRegisterCommandRequiresManifestDeclaration(), TestStorageKeysIsolatedPerPlugin(), TestStorageRejectsOversizedKeyAndValue(), openPluginTestDB(), TestDispatchCommandRuntimePlatformRace(), ParseManifest() (+8 more) +### Community 107 - "media-visibility.ts" +Cohesion: 0.13 +Nodes (19): allTracked, captureStaticFrame(), createPlayPauseButton(), destroyObserver(), ensureVisibilityListener(), freezeImage(), getObserver(), MediaEntry (+11 more) -### Community 108 - "voice_moderation_test.go" -Cohesion: 0.27 -Nodes (28): voiceMuteMsg(), auditActions(), joinVoice(), newVoiceModHub(), seedVoiceUserWithRole(), TestVoiceDeafen_SelfUndeafenWhileServerDeafened_Refused(), TestVoiceMod_Deafen_ClearingRestoresSelfUnmute(), TestVoiceMod_Deafen_SetsServerDeafenedAndMutes() (+20 more) +### Community 108 - "joinVoice" +Cohesion: 0.26 +Nodes (29): voiceMuteMsg(), auditActions(), joinVoice(), newVoiceModHub(), seedVoiceUserWithRole(), TestVoiceDeafen_SelfUndeafenWhileServerDeafened_Refused(), TestVoiceMod_Deafen_ClearingRestoresSelfUnmute(), TestVoiceMod_Deafen_SetsServerDeafenedAndMutes() (+21 more) ### Community 109 - "ptt.rs" Cohesion: 0.11 @@ -932,25 +938,25 @@ Nodes (28): 10. Appendix — session command log index, 11. Remediation addendum Cohesion: 0.07 Nodes (27): scripts, build, dev, format, format:check, knip, lint, lint:fix (+19 more) -### Community 112 - "ResolveTokenHash" -Cohesion: 0.17 -Nodes (9): fakeStore, tokenStore, ResolveTokenHash(), future(), past(), TestResolveTokenHash(), apiTokenFromGen(), APIToken (+1 more) +### Community 112 - "checkSourceWith" +Cohesion: 0.16 +Nodes (18): go/ast.File, go/ast.ImportSpec, go/token.FileSet, Rule, Violation, allowIndex(), CheckSource(), checkSourceWith() (+10 more) ### Community 113 - "Load" Cohesion: 0.16 -Nodes (22): IsDefaultVoiceCredentials(), Load(), TestIsDefaultVoiceCredentials(), TestLoadDefaults(), TestLoadEnvironmentVariableOverrides(), TestLoadEnvOverride_EventPersistence(), TestLoadEnvOverridesPrecedenceOverYAML(), TestLoadEnvVarNoUnderscore() (+14 more) +Nodes (23): IsDefaultVoiceCredentials(), Load(), TestIsDefaultVoiceCredentials(), TestLoadDefaults(), TestLoadEnvironmentVariableOverrides(), TestLoadEnvOverride_EventPersistence(), TestLoadEnvOverridesPrecedenceOverYAML(), TestLoadEnvVarNoUnderscore() (+15 more) -### Community 114 - "handleVoiceE2EEOfferV2" -Cohesion: 0.16 -Nodes (24): offerDeps(), TestVoiceE2EEOfferV2_EmptyFields(), TestVoiceE2EEOfferV2_HappyPath(), TestVoiceE2EEOfferV2_InvalidBase64(), TestVoiceE2EEOfferV2_NilKeyHolder_ReturnsInternal(), TestVoiceE2EEOfferV2_NoReply(), TestVoiceE2EEOfferV2_NotInVoiceChannel(), TestVoiceE2EEOfferV2_NotKeyHolder() (+16 more) +### Community 114 - "handleVoiceE2EEAnnounceV2" +Cohesion: 0.25 +Nodes (12): TestVoiceE2EEAnnounceV2_EmptyPublicKey(), TestVoiceE2EEAnnounceV2_HappyPath(), TestVoiceE2EEAnnounceV2_InvalidBase64(), TestVoiceE2EEAnnounceV2_NoReply(), TestVoiceE2EEAnnounceV2_NoSignature_LegacyAccepted(), TestVoiceE2EEAnnounceV2_NotInVoiceChannel(), TestVoiceE2EEAnnounceV2_PublicKeyTooLarge(), TestVoiceE2EEAnnounceV2_SignatureInvalidBase64() (+4 more) -### Community 115 - "commands.rs" +### Community 115 - "NewHandler" Cohesion: 0.14 -Nodes (15): get_cert_fingerprint(), get_identity_pin(), get_settings(), identity_pin_key(), is_settings_key_allowed(), log_cmd_err(), open_devtools(), AppHandle (+7 more) +Nodes (19): TestNewHandler_APIRoutesMounted(), TestNewHandler_AuthProtectedRoute(), TestNewHandler_ReturnsNonNilHandler(), TestNewHandler_ServesStaticRoot(), TestNewHandler_SetsCSPOnRoot(), TestNewHandler_WithUpdater(), TestOwnerOnlyMiddleware_AdminDenied(), TestOwnerOnlyMiddleware_MemberDenied() (+11 more) ### Community 116 - "newEmitTestHub" -Cohesion: 0.21 -Nodes (20): TestEmitEvents_PresenceEvent_UsesNormalPriorityQueue(), drainChan(), Hub, newEmitTestHub(), registerEmitTestClient(), registerEmitTestVoiceClient(), TestEmitEvents_BroadcastAllEvent(), TestEmitEvents_ChannelEvent_CallsBroadcastToChannel() (+12 more) +Cohesion: 0.18 +Nodes (21): TestEmitEvents_PresenceOthersEvent_UsesNormalPriorityQueue(), TestEmitEvents_PresenceEvent_UsesNormalPriorityQueue(), TestEmitEvents_PresenceSelfEvent_UsesNormalPriorityQueue(), drainChan(), Hub, newEmitTestHub(), registerEmitTestClient(), registerEmitTestVoiceClient() (+13 more) ### Community 117 - "Plan: Remediate security-hardening review regressions" Cohesion: 0.08 @@ -964,33 +970,33 @@ Nodes (15): aead.dev/minisign.PublicKey, os.File, TestEnsureVPrefix(), ensureVPr Cohesion: 0.17 Nodes (24): assertAudit(), newRoleCRUDService(), TestAffectedUserIDs(), TestCreateRole_CannotGrantUnheldBit(), TestCreateRole_CannotPlaceAtOrAboveOwnRank(), TestCreateRole_DefaultPlacementAvoidsCollision(), TestCreateRole_DefaultsToJustBelowActor(), TestCreateRole_HappyPath() (+16 more) -### Community 120 - "Checker" -Cohesion: 0.11 -Nodes (18): DB, ChannelOverride, ChannelRef, Checker, MessageService, Store, hasChannelAccess(), hasChannelAccessLive() (+10 more) +### Community 120 - "VoiceDeps" +Cohesion: 0.22 +Nodes (18): registerVoiceControlsV2(), disconnectFromVoiceIn(), Hub, handleVoiceModDeafenV2(), handleVoiceModKickV2(), handleVoiceModMoveV2(), handleVoiceModMuteV2(), onOff() (+10 more) ### Community 121 - "EnsureLiveKitBinary" -Cohesion: 0.15 -Nodes (24): io.Reader, io.ReaderAt, sync/atomic.Int32, extractChatserverFromTarGz(), TestExtractChatserverFromTarGz(), cleanupOldLiveKitBinaries(), downloadTo(), EnsureLiveKitBinary() (+16 more) +Cohesion: 0.17 +Nodes (23): io.Reader, io.ReaderAt, sync/atomic.Int32, cleanupOldLiveKitBinaries(), downloadTo(), EnsureLiveKitBinary(), ensureLiveKitStageBinary(), extractLiveKitFromTarGz() (+15 more) ### Community 122 - "clientip_test.go" -Cohesion: 0.12 -Nodes (30): contextKey, errorResponse, net.IPNet, TestBodyCapExemptions_RouteEnvelopesReachable(), inCIDRs(), TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(), TestClientIP_NoTrustedProxies_UsesRemoteAddr(), TestClientIP_RemoteAddrWithoutPort() (+22 more) +Cohesion: 0.18 +Nodes (24): net.IPNet, inCIDRs(), TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(), TestClientIP_NoTrustedProxies_UsesRemoteAddr(), TestClientIP_RemoteAddrWithoutPort(), TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(), TestClientIP_TrustedProxy_NoXRealIP_FallsBackToRemoteAddr(), TestClientIP_TrustedProxy_UsesXRealIP() (+16 more) -### Community 123 - "AudioElements" +### Community 123 - "connectionStats.ts" +Cohesion: 0.18 +Nodes (12): collectAllStats(), ConnectionStats, ConnectionStatsPoller, createConnectionStatsPoller(), EMPTY_STATS, extractMetrics(), formatBytes(), formatRate() (+4 more) + +### Community 124 - "buildVoiceLeave" Cohesion: 0.11 -Nodes (5): AudioElements, getSavedUserVolume(), userVolumeKey(), { mockLoadPref, mockSavePref }, mockVoiceStoreState - -### Community 124 - "buildErrorMsg" -Cohesion: 0.13 -Nodes (9): TestBuildErrorMsgWithID(), TestChannelCanSend(), Client, Hub, Client, Hub, buildErrorMsg(), buildErrorMsgWithID() (+1 more) +Nodes (13): github.com/livekit/protocol/livekit.WebhookEvent, Client, Hub, Client, Hub, MountWebhookRoute(), parseParticipantIdentity(), parseRoomChannelID() (+5 more) ### Community 125 - "gif_handler_test.go" Cohesion: 0.30 Nodes (19): lastRequest, buildGIFRouter(), decodeGIFError(), decodeGIFResults(), gifGET(), stubKlipy(), TestGIFAuthCheckedBeforeDisabledCheck(), TestGIFDisabledMakesNoUpstreamCall() (+11 more) -### Community 126 - "navigateToMainPage" -Cohesion: 0.19 -Nodes (6): emitWsEvent(), emitWsMessage(), mockTauriFullSession(), mockTauriFullSessionWithMessages(), navigateToMainPage(), simulateReconnect() +### Community 126 - "e2e/helpers.ts" +Cohesion: 0.14 +Nodes (12): MOCK_INVITES, MOCK_MESSAGES_RICH, MOCK_READY_PAYLOAD, mockTauriFullSession(), mockTauriFullSessionWithAutoConnect(), mockTauriFullSessionWithEcho(), mockTauriFullSessionWithFailingMessages(), mockTauriFullSessionWithMessages() (+4 more) ### Community 127 - "messages.sql.go" Cohesion: 0.13 @@ -1001,32 +1007,32 @@ Cohesion: 0.09 Nodes (23): Channel Endpoints, DELETE /api/v1/channels/{id}/pins/{messageId}, Errors, GET /api/v1/channels, GET /api/v1/channels/{id}/messages, GET /api/v1/channels/{id}/messages/around/{messageId}, GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users, GET /api/v1/channels/{id}/pins (+15 more) ### Community 129 - "newSignedTestUpdater" -Cohesion: 0.19 -Nodes (23): aead.dev/minisign.PrivateKey, multiAssetManifest(), testHash(), TestVerifyReleaseManifest_LegacySingleAssetStillVerifies(), TestVerifyReleaseManifest_MultiAssetBadChecksumFails(), TestVerifyReleaseManifest_MultiAssetSelectsLinuxEntry(), TestVerifyReleaseManifest_MultiAssetSelectsWindowsEntry(), TestVerifyReleaseManifest_MultiAssetUnknownAssetFails() (+15 more) +Cohesion: 0.20 +Nodes (22): aead.dev/minisign.PrivateKey, multiAssetManifest(), testHash(), TestVerifyReleaseManifest_LegacySingleAssetStillVerifies(), TestVerifyReleaseManifest_MultiAssetBadChecksumFails(), TestVerifyReleaseManifest_MultiAssetSelectsLinuxEntry(), TestVerifyReleaseManifest_MultiAssetSelectsWindowsEntry(), TestVerifyReleaseManifest_MultiAssetUnknownAssetFails() (+14 more) ### Community 130 - "host_http_test.go" -Cohesion: 0.14 -Nodes (18): net.Conn, net.IP, HTTPRequest, HTTPResponse, Registry, GuardedDialContext(), ipAllowed(), Registry (+10 more) +Cohesion: 0.11 +Nodes (21): net.Conn, net.IP, net.Listener, HTTPRequest, HTTPResponse, TestEmptyAllowlistDeniesEveryHost(), Registry, GuardedDialContext() (+13 more) ### Community 131 - "Topic" -Cohesion: 0.19 -Nodes (8): TestEmitEvents_DirectPresenceDropsQueuedEntry(), Client, NewPubSub(), TestUserTopic(), topicFor(), UserTopic(), PubSub, Topic +Cohesion: 0.32 +Nodes (3): Client, PubSub, Topic ### Community 132 - "DB" -Cohesion: 0.09 -Nodes (13): mentionExecer, ChannelOverride, DB, MentionTarget, Message, insertMentionRows(), LowerASCII(), BroadcastStatus() (+5 more) +Cohesion: 0.13 +Nodes (9): mentionExecer, mentionTargetColumn, ChannelOverride, DB, MentionTarget, Message, insertMentionRows(), LowerASCII() (+1 more) ### Community 133 - "users" Cohesion: 0.14 Nodes (15): channel_overrides, channels, messages, messages_fts, roles, sessions, users, voice_states (+7 more) -### Community 134 - "Client" -Cohesion: 0.12 -Nodes (4): Hub, Client, newClient(), wsConn +### Community 134 - "User" +Cohesion: 0.05 +Nodes (24): adminContextKey, adminMeResponse, adminUserResponse, fakeStore, tokenStore, adminAuthMiddleware(), RequireAdminAuth(), requirePerm() (+16 more) -### Community 135 - "identity.ts" -Cohesion: 0.19 -Nodes (22): exportIdentityKeyPair(), exportPublicKey(), generateIdentityKeyPair(), importIdentityKeyPair(), deleteIdentityKey(), ensureIdentityKeyPublished(), getIdentityPin(), getInvoke() (+14 more) +### Community 135 - "update_commands.rs" +Cohesion: 0.22 +Nodes (14): build_tls_config(), build_update_endpoint(), build_updater(), check_client_update(), download_and_install_update(), DownloadProgress, extract_host_for_cert_store(), AppHandle (+6 more) ### Community 136 - "Queries" Cohesion: 0.12 @@ -1041,20 +1047,20 @@ Cohesion: 0.12 Nodes (9): GetAuditLogParams, GetAuditLogRow, ListAllUsersParams, ListAllUsersRow, LogAuditParams, SetSettingParams, UpdateUserRoleParams, Queries (+1 more) ### Community 139 - "middleware_and_spawn_test.go" -Cohesion: 0.16 -Nodes (20): adminAuthMiddleware(), isolateSpawnedTestBinary(), openWhiteboxTestDB(), TestAdminAuthMiddleware_RoleNotFound(), TestHandleGetAuditLog_DBError(), TestHandleGetSettings_DBError(), TestHandleGetStats_DBError(), TestHandleListChannels_DBError() (+12 more) +Cohesion: 0.15 +Nodes (21): handleListUsers(), TestQueryInt_LimitStillCapped(), TestQueryInt_OffsetNotClampedByLimitCap(), queryInt(), isolateSpawnedTestBinary(), openWhiteboxTestDB(), TestAdminAuthMiddleware_RoleNotFound(), TestHandleGetAuditLog_DBError() (+13 more) ### Community 140 - "password_test.go" -Cohesion: 0.14 -Nodes (19): CheckPassword(), FuzzValidatePasswordStrength(), getDummyHash(), TestCheckPassword_CorrectPassword(), TestCheckPassword_EmptyHash(), TestCheckPassword_EmptyHashTimingResistance(), TestCheckPassword_EmptyPassword(), TestCheckPassword_MalformedHash() (+11 more) +Cohesion: 0.16 +Nodes (18): CheckPassword(), getDummyHash(), TestCheckPassword_CorrectPassword(), TestCheckPassword_EmptyHash(), TestCheckPassword_EmptyHashTimingResistance(), TestCheckPassword_EmptyPassword(), TestCheckPassword_MalformedHash(), TestCheckPassword_WrongPassword() (+10 more) ### Community 141 - "newPurgeService" Cohesion: 0.19 Nodes (20): TestAddReaction_RefusedInArchivedChannel(), TestEditMessage_RefusedInArchivedChannel(), TestPurgeMessages_RefusedInArchivedChannel(), TestSendMessage_AllowedAfterUnarchive(), TestSendMessage_RefusedInArchivedChannel(), TestSetMessagePinned_RefusedInArchivedChannel(), MessageService, newPurgeService() (+12 more) ### Community 142 - "handleVoiceTokenRefreshV2" -Cohesion: 0.18 -Nodes (16): seedTokenRefreshUser(), seedVoiceOnlyRole(), TestVoiceTokenRefreshV2_GenerateTokenError(), TestVoiceTokenRefreshV2_HappyPath(), TestVoiceTokenRefreshV2_IsKeyHolderReflectedInReply(), TestVoiceTokenRefreshV2_NoEvents(), TestVoiceTokenRefreshV2_NotInVoice(), TestVoiceTokenRefreshV2_PermissionsPassedToTokenGen() (+8 more) +Cohesion: 0.16 +Nodes (17): seedTokenRefreshUser(), seedVoiceOnlyRole(), TestVoiceTokenRefreshV2_GenerateTokenError(), TestVoiceTokenRefreshV2_HappyPath(), TestVoiceTokenRefreshV2_IsKeyHolderReflectedInReply(), TestVoiceTokenRefreshV2_NoEvents(), TestVoiceTokenRefreshV2_NotInVoice(), TestVoiceTokenRefreshV2_PermissionsPassedToTokenGen() (+9 more) ### Community 143 - "pubsub_test.go" Cohesion: 0.26 @@ -1069,60 +1075,56 @@ Cohesion: 0.13 Nodes (13): gapProbeSSEWriter, revokingSSEWriter, lockedBuffer, bytes.Buffer, net/http.Header, TestHandleLogStream_EntryWrittenDuringBackfillIsDelivered(), TestRingBuffer_SnapshotAndSubscribe_NoGap(), TestRingBuffer_SnapshotAndSubscribe_SnapshotExcludedFromChannel() (+5 more) ### Community 146 - "handleSetup" -Cohesion: 0.18 -Nodes (18): setupDefaults, SetupOptions, setupRequest, setupResponse, setupStatusResponse, setupWizardRequest, handleSetup(), isSameOrigin() (+10 more) +Cohesion: 0.15 +Nodes (26): setupDefaults, SetupOptions, setupRequest, setupResponse, setupStatusResponse, setupWizardRequest, handleSetup(), handleSetupStatus() (+18 more) ### Community 147 - "log/slog.Value" Cohesion: 0.14 Nodes (9): log/slog.Value, logAttrValue(), Config, GIFConfig, GitHubConfig, VoiceConfig, redactSecret(), Session (+1 more) ### Community 148 - "Updater" -Cohesion: 0.16 -Nodes (14): tauriPlatformResponse, tauriUpdateResponse, golang.org/x/sync/singleflight.Group, ensureV(), chi.Router, handleClientUpdate(), MountClientUpdateRoute(), Updater (+6 more) +Cohesion: 0.14 +Nodes (16): tauriPlatformResponse, tauriUpdateResponse, golang.org/x/sync/singleflight.Group, net/http.Client, ensureV(), chi.Router, handleClientUpdate(), MountClientUpdateRoute() (+8 more) -### Community 149 - "Credential storage" -Cohesion: 0.25 -Nodes (8): Credential storage, Environment causes that remain possible, From the client, From Windows directly, Root cause of the 2026-07 identity-key regression, The fix, Verifying the credential store on a machine, Write verification and the fallback store +### Community 149 - "syntax-highlight.ts" +Cohesion: 0.12 +Nodes (14): ALIASES, BACKTICK_STRING, C_BLOCK_COMMENT, CodeToken, DQ_STRING, HASH_COMMENT, JS_LIKE, LANGS (+6 more) ### Community 150 - "dependencies" Cohesion: 0.09 Nodes (23): dependencies, @jitsi/rnnoise-wasm, livekit-client, @tauri-apps/api, @tauri-apps/plugin-autostart, @tauri-apps/plugin-deep-link, @tauri-apps/plugin-dialog, @tauri-apps/plugin-fs (+15 more) ### Community 151 - "newHarvestVoiceDB" -Cohesion: 0.18 -Nodes (18): mustCreateVoiceChannel(), newHarvestVoiceDB(), seedHarvestVoiceUser(), TestHandleVoiceCameraV2_ChannelLookupErrorFailsClosed(), TestHandleVoiceJoin_AbortedSwitchDoesNotResurrectVoiceTopicSubscription(), TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel(), TestRefreshChannelVisibility_ReconnectDuringFanOutActsOnLiveClient(), TestCleanupVoiceForChannel_NotifiesReadAudienceOfArchivedChannel() (+10 more) +Cohesion: 0.14 +Nodes (22): mustCreateVoiceChannel(), newHarvestVoiceDB(), seedHarvestVoiceUser(), TestHandleVoiceCameraV2_ChannelLookupErrorFailsClosed(), TestHandleVoiceJoin_AbortedSwitchDoesNotResurrectVoiceTopicSubscription(), TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel(), TestRefreshChannelVisibility_ReconnectDuringFanOutActsOnLiveClient(), TestCleanupVoiceForChannel_NotifiesReadAudienceOfArchivedChannel() (+14 more) ### Community 152 - "Config Key Reference" Cohesion: 0.10 Nodes (21): Backups (`backup`), Config Key Reference, Database (`database`), Environment Variable Overrides, Event Persistence (`event_persistence`), Example config.yaml, First-run setup wizard, GIF Picker (`gif`) (+13 more) -### Community 153 - "newTestPermService" -Cohesion: 0.21 -Nodes (15): TestCacheStats_HitsAndMisses(), newTestPermService(), TestHasChannelPerm_Allowed(), TestHasChannelPerm_Denied(), TestHasChannelPerm_OverrideAllow(), TestHasChannelPerm_OverrideDeny(), TestHasChannelPerm_UnknownUserReturnsFalse(), TestInvalidateAll_ClearsEntireCache() (+7 more) +### Community 153 - "seedChannel" +Cohesion: 0.22 +Nodes (20): TestCacheStats_HitsAndMisses(), newTestPermService(), TestHasChannelPerm_AdminBypass(), TestHasChannelPerm_Allowed(), TestHasChannelPerm_Denied(), TestHasChannelPerm_OverrideAllow(), TestHasChannelPerm_OverrideDeny(), TestHasChannelPerm_OverrideFetchErrorDenies() (+12 more) ### Community 154 - "markdown.ts" Cohesion: 0.18 Nodes (19): BlockNode, buildMatches(), codeSpanEnd(), DELIMS, DelimSpec, EMPTY_MATCHES, InlineNode, InlineStyle (+11 more) -### Community 155 - "VideoGrid.ts" -Cohesion: 0.05 -Nodes (33): appendModerationSection(), showUserVolumeMenu(), VoiceModMenuOptions, computeGridLayout(), createVideoGrid(), GridLayout, setButtonIcon(), TileConfig (+25 more) - -### Community 156 - "Manifest" -Cohesion: 0.22 -Nodes (9): Capability, CommandSpec, Resources, UISpec, UITab, Manifest, FuzzValidateRelativePath(), TestValidateRelativePath() (+1 more) +### Community 155 - "deep-link.ts" +Cohesion: 0.09 +Nodes (16): initDeepLinks(), InviteLink, linkSegments(), log, MessageLink, parseIdSegment(), parseInviteLink(), parseMessageLink() (+8 more) ### Community 157 - "rate-limiter.ts" Cohesion: 0.22 Nodes (12): createChatLimiter(), createPresenceLimiter(), createRateLimiter(), createRateLimiterSet(), createReactionLimiter(), createTypingLimiter(), createVideoCameraLimiter(), createVoiceLimiter() (+4 more) -### Community 158 - "e2e/helpers.ts" -Cohesion: 0.18 -Nodes (11): MOCK_INVITES, MOCK_MESSAGES_RICH, MOCK_READY_PAYLOAD, mockTauriConnect(), mockTauriConnectWith2FA(), mockTauriFullSessionWithEcho(), mockTauriFullSessionWithMessagesAndEcho(), mockTauriLoginError() (+3 more) +### Community 158 - "Error" +Cohesion: 0.48 +Nodes (7): CertificateDer, Error, StubVerifier, verify_tls12(), verify_tls13(), DigitallySignedStruct, HandshakeSignatureValid -### Community 159 - "main.test.ts" -Cohesion: 0.23 -Nodes (13): createWsClient(), ensureTauriApis(), normalizeHostForCertCompare(), parseStoredFingerprint(), uuid(), emitTauriEvent(), eventHandlers, mockInvoke (+5 more) +### Community 159 - "video-grid.test.ts" +Cohesion: 0.14 +Nodes (7): TileConfig, mockGetScreenshareAudioMuted, mockGetScreenshareAudioVolume, mockGetUserVolume, mockMuteScreenshareAudio, mockSetScreenshareAudioVolume, mockSetUserVolume ### Community 160 - "testing.M" Cohesion: 0.11 @@ -1141,20 +1143,20 @@ Cohesion: 0.11 Nodes (15): allResults, ARGS, byFile, clusters, commits, excluded, FIX_RESULTS, fixed (+7 more) ### Community 164 - "buildTauriMockScript" -Cohesion: 0.13 -Nodes (13): buildReadyPayload(), buildTauriMockScript(), chatEchoHandlers(), MOCK_LOGIN_2FA_RESPONSE, MOCK_LOGIN_RESPONSE, MOCK_TOKEN, mockTauriFullSessionWithAutoConnect(), mockTauriFullSessionWithFailingMessages() (+5 more) +Cohesion: 0.12 +Nodes (14): buildReadyPayload(), buildTauriMockScript(), chatEchoHandlers(), MOCK_LOGIN_2FA_RESPONSE, MOCK_LOGIN_RESPONSE, MOCK_TOKEN, mockTauriConnect(), mockTauriConnectWith2FA() (+6 more) ### Community 165 - "OwnCord Introspection MCP Server" -Cohesion: 0.08 -Nodes (22): 1. Install dependencies, 2. Mint an API token, 3. Put the token in your environment, 4. Enable in Claude Code, `api_request`, API tokens (server side), Authentication, `client_logs` (+14 more) +Cohesion: 0.11 +Nodes (19): 1. Install dependencies, 2. Mint an API token, 3. Put the token in your environment, 4. Enable in Claude Code, `api_request`, API tokens (server side), Authentication, `client_logs` (+11 more) ### Community 166 - "Bug-detection improvements — design" Cohesion: 0.11 Nodes (18): 1a. `make fuzz`, 1b. Scoped Stryker runs, 1c. Browser-mode vitest, 1d. Prerequisite, 3a. Client model-based tests, 3b. Server hub simulation, 3c. Fault-injected transport, Bug-detection improvements — design (+10 more) -### Community 167 - "ptt.ts" -Cohesion: 0.16 -Nodes (19): buildKeybindsTab(), captureKeyPress(), initPtt(), log, stopPtt(), ungateMic(), updatePttKey(), VK_NAMES (+11 more) +### Community 167 - "AuditWriter" +Cohesion: 0.20 +Nodes (6): AuditStore, pendingAudit, sync/atomic.Uint64, sync.Once, AuditWriter, DB ### Community 168 - "eslint-rules.js" Cohesion: 0.12 @@ -1173,28 +1175,28 @@ Cohesion: 0.11 Nodes (17): Built-in commands, Code surface, Concurrency & lifecycle, Failure modes & UX, Files-to-touch checklist (for the implementing agent), Manifest changes, Non-goals, Open questions (+9 more) ### Community 172 - "net/http.Request" -Cohesion: 0.25 -Nodes (11): PluginAdminHandler, net/http.Request, net/http.ResponseWriter, okHandler(), ok(), hasZipMagic(), isZipContentType(), parsePluginID() (+3 more) +Cohesion: 0.12 +Nodes (31): memberUnbanBroadcaster, patchUserRequest, PluginAdminHandler, uploadResponse, net/http.Request, net/http.ResponseWriter, handlePatchUser(), patchUserApplyBan() (+23 more) ### Community 173 - "ChannelTopic" -Cohesion: 0.16 -Nodes (15): TestEmitSequencedDM_UsesNormalQueueToPreserveSeqOrder(), TestEmitUserTargeted_KeepsHighPriorityFastLane(), NewTestClientWithChannel(), TestComputeAllowedChannels_DMLookupErrorIsFatal(), TestDeliverBroadcast_ShedFrameLeavesNoSeqGap(), TestEmitEvents_DMChannelOpenForcesFullResyncForOlderClients(), TestFailedHandshake_MarksUserOfflineWhenNoReplacementRemains(), TestKickClient_SubscribeRaceCannotLeaveDeadSubscriber() (+7 more) +Cohesion: 0.13 +Nodes (16): TestEmitSequencedDM_UsesNormalQueueToPreserveSeqOrder(), TestEmitUserTargeted_KeepsHighPriorityFastLane(), NewTestClientWithChannel(), TestComputeAllowedChannels_DMLookupErrorIsFatal(), TestDeliverBroadcast_ShedFrameLeavesNoSeqGap(), TestEmitEvents_DMChannelOpenForcesFullResyncForOlderClients(), TestFailedHandshake_MarksUserOfflineWhenNoReplacementRemains(), TestKickClient_SubscribeRaceCannotLeaveDeadSubscriber() (+8 more) ### Community 174 - "UserService" -Cohesion: 0.19 -Nodes (9): github.com/owncord/server/syncutil.Mutex, cleanText(), Store, nullable(), resolveOptional(), ChangePasswordResult, keyedMutex, ProfilePatch (+1 more) - -### Community 175 - "Blocked — fix attempted, revert-proof failed" -Cohesion: 0.14 -Nodes (13): Blocked — fix attempted, revert-proof failed, Declined, OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever, OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch, OC-0012 — low — CleanupVoiceForChannel never clears voiceKeyHolders, OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message, OC-0058 — low — Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster, OC-0081 — low — voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable (+5 more) - -### Community 176 - "handlers_backup.go" Cohesion: 0.25 -Nodes (8): backupEntry, absOrRaw(), closeDatabase(), handleRestoreBackup(), init(), requestRestart(), SetBackupDir(), CheckBackupIntegrity() +Nodes (6): cleanText(), nullable(), resolveOptional(), ChangePasswordResult, ProfilePatch, UserService + +### Community 175 - "OwnCord Findings Ledger" +Cohesion: 0.29 +Nodes (6): Declined, Duplicate, OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message, OC-0092 — low — Plugin slash-command broadcast can post live content into an archived (read-only) channel, OC-0159 — medium — Handshake/replay writes have no write deadline, so a client that stops reading pins a server goroutine + socket forever, OwnCord Findings Ledger + +### Community 176 - "handleRestoreBackup" +Cohesion: 0.10 +Nodes (25): backupEntry, backupFile, MaintainBackups(), pruneExpiredBackups(), runScheduledBackup(), scanBackups(), absOrRaw(), closeDatabase() (+17 more) ### Community 177 - "logger.ts" -Cohesion: 0.11 -Nodes (24): createLogsTab(), formatLogEntry(), LOG_FILTER_LEVELS, LOG_LEVEL_COLORS, LOG_MIN_LEVELS, LogsTabHandle, TabName, getSessionDebugInfo (+16 more) +Cohesion: 0.06 +Nodes (48): createLogsTab(), formatLogEntry(), LOG_FILTER_LEVELS, LOG_LEVEL_COLORS, LOG_MIN_LEVELS, LogsTabHandle, TabName, createUpdateNotifier() (+40 more) ### Community 178 - "fallback_crypto.rs" Cohesion: 0.25 @@ -1220,9 +1222,9 @@ Nodes (24): log/slog.Leveler, TestRingBuffer_WriteDoesNotAllocate(), NewMultiHan Cohesion: 0.12 Nodes (14): authTime, broadcastLatency, CHANNEL_ID, handleSummary(), options, textSummary(), wsAcks, wsAuthed (+6 more) -### Community 184 - "NewMessageService" +### Community 184 - "newDMFixture" Cohesion: 0.23 -Nodes (15): Store, newDMFixture(), TestDeleteMessage_DMFanoutSurvivesDeleterDisconnectAfterCommit(), TestDeleteMessage_FailsClosedWhenChannelLookupErrors(), TestDeleteMessage_RefusedInArchivedChannel(), TestEditMessage_DMFanoutSurvivesEditorDisconnectAfterCommit(), TestEditMessage_FailsClosedWhenChannelLookupErrors(), TestSendMessage_AttachmentsSurviveSenderDisconnectAfterLink() (+7 more) +Nodes (11): Message, newDMFixture(), TestDeleteMessage_DMFanoutSurvivesDeleterDisconnectAfterCommit(), TestDeleteMessage_FailsClosedWhenChannelLookupErrors(), TestDeleteMessage_RefusedInArchivedChannel(), TestEditMessage_DMFanoutSurvivesEditorDisconnectAfterCommit(), TestEditMessage_FailsClosedWhenChannelLookupErrors(), TestSendMessage_DMFanoutSurvivesSenderDisconnectAfterCommit() (+3 more) ### Community 185 - "handler" Cohesion: 0.09 @@ -1256,17 +1258,17 @@ Nodes (16): DELETE /api/v1/users/me/sessions/{id}, Errors, Errors, GET /api/v1/u Cohesion: 0.12 Nodes (15): Client session (`livekitSession.ts`), Compatibility posture (transition), F3 status 2026-07-23 (branch `feat/e2ee-identity-tofu`), F3 — Voice E2EE identity keys + TOFU (the remaining work), F6 detail (done, committed `e6a0d87`), Infrastructure (mirror existing patterns), Notes carried from the build, Resume checklist (do these first) (+7 more) -### Community 194 - "run" -Cohesion: 0.16 -Nodes (18): log/slog.LevelVar, log/slog.Logger, SetDatabasePath(), ParseLevel(), TestLoggingLevelFromEnv(), TestParseLevel(), getOutboundIP(), healthcheckTLSConfig() (+10 more) +### Community 194 - "Server/main.go" +Cohesion: 0.08 +Nodes (45): log/slog.LevelVar, log/slog.Logger, net/http.Server, time.Timer, SetDatabasePath(), serveWithBindRetry(), getOutboundIP(), healthcheckTLSConfig() (+37 more) -### Community 195 - "newWazeroTestRegistry" -Cohesion: 0.35 -Nodes (13): Registry, newWazeroTestRegistry(), TestWazeroActivateCompilesModule(), TestWazeroCloseTearsDownRuntime(), TestWazeroConcurrentDispatchRace(), TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(), TestWazeroDeactivateClosesCompiledModule(), TestWazeroDisablePluginFreesModule() (+5 more) +### Community 195 - "totp_encrypt_test.go" +Cohesion: 0.32 +Nodes (11): DecryptTOTPSecret(), EncryptTOTPSecret(), LoadOrGenerateTOTPKey(), TestDecryptTOTPSecret_FailsClosed(), TestDecryptTOTPSecret_LegacyPlaintextPassthrough(), TestEncryptDecryptTOTPSecret_RoundTrip(), TestEncryptTOTPSecret_NonceIsRandom(), testKey() (+3 more) ### Community 196 - "newUserSvc" -Cohesion: 0.23 -Nodes (13): Store, newUserSvc(), TestClearCustomStatus(), TestSetCustomStatus_RoundTripClearAndBound(), TestUpdateProfile_AvatarOnlyPatchDoesNotOverwriteUsername(), TestUpdateProfile_ConcurrentUpdatesSerializePerUser(), TestUpdateProfile_RejectsOverlongFields(), TestUpdateProfile_SanitizesAndTrims() (+5 more) +Cohesion: 0.21 +Nodes (14): Store, newUserSvc(), TestClearCustomStatus(), TestSetCustomStatus_RoundTripClearAndBound(), TestUpdateProfile_AvatarOnlyPatchDoesNotOverwriteUsername(), TestUpdateProfile_ConcurrentUpdatesSerializePerUser(), TestUpdateProfile_RejectsOverlongFields(), TestUpdateProfile_SanitizesAndTrims() (+6 more) ### Community 197 - "plugins_handler_test.go" Cohesion: 0.34 @@ -1280,37 +1282,41 @@ Nodes (5): badDirFile, fakeDir, fakeDirEntry, io/fs.DirEntry, io/fs.FileInfo Cohesion: 0.13 Nodes (15): Active Branches, Available Commands, Branch Naming, Client (Tauri v2), Code Style, Commit Format, Contributing, Dependency Policy (+7 more) -### Community 200 - ".handleFreshConnect" -Cohesion: 0.30 -Nodes (6): github.com/coder/websocket.Conn, applyConnectStatus(), authenticateConn(), Client, Hub, resumeHint +### Community 200 - "github.com/coder/websocket.Conn" +Cohesion: 0.18 +Nodes (14): github.com/coder/websocket.Conn, applyConnectStatus(), Client, Hub, handshakeWrite(), Client, Hub, TestWritePump_DrainsAllNormalBeforeAnyLow() (+6 more) ### Community 201 - "mcp-introspect/package.json" Cohesion: 0.13 Nodes (14): @modelcontextprotocol/sdk, dependencies, @modelcontextprotocol/sdk, zod, description, engines, node, name (+6 more) ### Community 202 - "v1.2.0-alpha.1 — Discord feature parity" -Cohesion: 0.08 -Nodes (24): Behavioural changes operators must know about, Changelog, Deferred work, Messaging & mentions, Phase B — Acceleration, Phase C — Differentiation, Roles, permissions & moderation, Security (+16 more) +Cohesion: 0.06 +Nodes (32): Behavioural changes operators must know about, Changelog, Deferred work, Messaging & mentions, Phase B — Acceleration, Phase C — Differentiation, Roles, permissions & moderation, Security (+24 more) ### Community 203 - "Task Observer — Continuous Skill Discovery & Improvement" Cohesion: 0.14 Nodes (13): Acting on Observations, Archival on Write, How to Log, Log Structure, Quick Reference, Reference files — load on demand, not up front, Referencing Observations, Session Start Protocol (+5 more) -### Community 204 - "scanPluginDirectory" -Cohesion: 0.24 -Nodes (10): foundPlugin, Manifest, rejectSymlinksUnder(), scanPluginDirectory(), TestRejectSymlinksUnderClean(), TestRejectSymlinksUnderFindsNestedSymlink(), TestRejectSymlinksUnderFindsSymlink(), TestScanPluginDirectoryRejectsSymlinkEntrypoint() (+2 more) +### Community 204 - "handleVoiceE2EEOfferV2" +Cohesion: 0.38 +Nodes (12): offerDeps(), TestVoiceE2EEOfferV2_EmptyFields(), TestVoiceE2EEOfferV2_HappyPath(), TestVoiceE2EEOfferV2_InvalidBase64(), TestVoiceE2EEOfferV2_NilKeyHolder_ReturnsInternal(), TestVoiceE2EEOfferV2_NoReply(), TestVoiceE2EEOfferV2_NotInVoiceChannel(), TestVoiceE2EEOfferV2_NotKeyHolder() (+4 more) + +### Community 205 - "AudioPipeline" +Cohesion: 0.14 +Nodes (3): AudioPipeline, { mockLoadPref, mockSavePref }, { mockLoadPref, mockSavePref } ### Community 206 - "1. Channel sidebar" Cohesion: 0.14 Nodes (14): 1.1 Channel type affordances, 1.1a Per-channel notification mutes, 1.2 Channel switching, 1.3 Reorder & CRUD (admin), 1. Channel sidebar, 2.1 Typing indicator, 2.2 Member actions (context menu), 2. Member list (+6 more) -### Community 207 - "Voice, Video & E2EE — target UX" -Cohesion: 0.09 -Nodes (20): 1. View-state vocabulary, 2. Feedback primitives, 3. Connection status is a first-class, observable state, 4. Global event → reaction map, 5. Error & permission reaction matrix, 6. Cross-cutting principles, Documents, Maintenance rule (+12 more) +### Community 207 - "Messaging — target UX" +Cohesion: 0.08 +Nodes (23): 1. Message list — states, 2. Composer — permission & connection gating, 3. Sending — optimistic lifecycle, 4. Edit / delete, 5. Reactions, 6. Attachments, 7. Replies, pins, search, read/unread, 7a. Jumping to a message (+15 more) -### Community 208 - "Updater" -Cohesion: 0.15 -Nodes (8): assetFilenameFromURL(), Updater, isGitHubHost(), TestIsGitHubHost(), Updater, TestAssetFilenameFromURL(), ClientAssets, textAssetCacheEntry +### Community 208 - "time.Time" +Cohesion: 0.07 +Nodes (17): touchThrottle, failingLockoutStore, Event, GetEventsSinceParams, GetEventsSinceRow, PersistEventParams, time.Time, Queries (+9 more) ### Community 209 - "LiveKitClient" Cohesion: 0.13 @@ -1324,29 +1330,29 @@ Nodes (13): io/fs.FS, applyMigration(), ensureSchemaVersions(), DB, isApplied(), Cohesion: 0.15 Nodes (7): CountRoleMembersRow, CreateRoleParams, GetUserWithRoleRow, SetRolePositionParams, UpdateRoleParams, Queries, Role -### Community 212 - "Messaging — target UX" -Cohesion: 0.14 -Nodes (14): 1. Message list — states, 2. Composer — permission & connection gating, 3. Sending — optimistic lifecycle, 4. Edit / delete, 5. Reactions, 6. Attachments, 7. Replies, pins, search, read/unread, 7a. Jumping to a message (+6 more) +### Community 212 - "setupRouter" +Cohesion: 0.32 +Nodes (11): setupRouter(), TestAPIV1InfoEndpoint(), TestAPIV1InfoOmitsVersion(), TestAPIV1InfoReturnsServerName(), TestHealthEndpointOmitsVersion(), TestHealthEndpointReturns200(), TestHealthEndpointReturnsJSON(), TestHealthEndpointStatusOK() (+3 more) -### Community 213 - "Registry" -Cohesion: 0.18 -Nodes (6): bytesReaderAt, Config, UITabBinding, PluginStore, Registry, TestBytesReaderAt() +### Community 213 - ".verify_server_cert" +Cohesion: 0.36 +Nodes (6): capture_verifier_records_leaf_not_intermediate(), fingerprint_hex(), ServerName, verify(), ServerCertVerified, UnixTime ### Community 214 - "handleChannelFocusV2" Cohesion: 0.40 Nodes (10): newFocusTestDeps(), TestChannelFocusV2_ChannelNotFound_SilentDrop(), TestChannelFocusV2_HappyPath_SetsChannelID(), TestChannelFocusV2_InvalidChannelID_SilentDrop(), TestChannelFocusV2_NoEvents(), TestChannelFocusV2_NoPermission_ReturnsForbidden(), TestChannelFocusV2_RateLimited_SilentDrop(), TestMarkReadV2_RateLimited_SkipsReadStateWrite() (+2 more) -### Community 215 - "handlers_channel_perms_test.go" -Cohesion: 0.15 -Nodes (10): mockPermInvalidator, TestDeleteChannelPermission_ClearsOverride(), TestDeleteChannelPermission_UnknownRole(), TestGetChannelPermissions_DMRejected(), TestGetChannelPermissions_NotFound(), TestGetChannelPermissions_ReturnsAllRoles(), TestPutChannelPermission_AdministratorCanGrantAnyBit(), TestPutChannelPermission_MasksUnknownBits() (+2 more) +### Community 215 - "itoa" +Cohesion: 0.11 +Nodes (27): mockPermInvalidator, unbanMockHub, itoa(), TestDeleteChannelPermission_ClearsOverride(), TestDeleteChannelPermission_RefusesEqualOrHigherRole(), TestDeleteChannelPermission_UnknownRole(), TestGetChannelPermissions_DMRejected(), TestPutChannelPermission_AdministratorCanGrantAnyBit() (+19 more) ### Community 216 - "knip.json" Cohesion: 0.15 Nodes (12): entry, ignore, ignoreDependencies, ignoreExportsUsedInFile, public/**, project, $schema, src/lib/protocolTypes.ts (+4 more) ### Community 218 - "DeviceManager" -Cohesion: 0.16 -Nodes (5): DeviceManager, log, mockGetLocalDevices, { mockLoadPref, mockSavePref }, mockVoiceState +Cohesion: 0.13 +Nodes (6): DeviceManager, isMicPolicyGated(), setLocalMuted(), mockGetLocalDevices, { mockLoadPref, mockSavePref }, mockVoiceState ### Community 219 - "finish" Cohesion: 0.38 @@ -1368,17 +1374,17 @@ Nodes (13): 1. Settings overlay, 2.1 Profile edit, 2.2 Change password (with ses Cohesion: 0.41 Nodes (12): buildClientUpdateRouter(), fakeGitHubRelease(), platformEntry(), TestClientUpdate_AlreadyLatest(), TestClientUpdate_DebTargetNoContent(), TestClientUpdate_FutureVersion(), TestClientUpdate_GitHubError(), TestClientUpdate_LinuxArm64TargetGetsAarch64AppImage() (+4 more) -### Community 224 - "logPersistence.ts" -Cohesion: 0.19 -Nodes (13): addLogListener(), buffer, flushBuffer(), flushLogs(), initLogPersistence(), log, logFilePath(), onLogEntry() (+5 more) +### Community 224 - "Voice, Video & E2EE — target UX" +Cohesion: 0.18 +Nodes (11): 1. Two state machines, one status, 2. Join / leave, 3. Local controls, 4. Push-to-talk, 5. Voice roster (per channel), 6. Token refresh & reconnect (invisible), 7. E2EE identity verification surface, 8. Media processing & devices (+3 more) ### Community 225 - "newTestRoleService" -Cohesion: 0.31 -Nodes (12): newTestModerationService(), newTestRoleService(), roleIDOf(), TestBanUser_AuthorizedSucceeds(), TestBanUser_HierarchyEnforced(), TestBanUser_RequiresBanPermission(), TestChangeUserRole_AuditWritten(), TestChangeUserRole_CannotAssignAtOrAboveOwnRank() (+4 more) +Cohesion: 0.23 +Nodes (14): Store, NewModerationService(), newTestModerationService(), newTestRoleService(), roleIDOf(), TestBanUser_AuthorizedSucceeds(), TestBanUser_HierarchyEnforced(), TestBanUser_RequiresBanPermission() (+6 more) ### Community 226 - "event.go" -Cohesion: 0.27 -Nodes (11): presenceEvents(), BroadcastAllEvent, ChannelEvent, ClientError, Event, ExcludeSenderEvent, SequencedDMEvent, UserTargetedEvent (+3 more) +Cohesion: 0.22 +Nodes (12): presenceEvents(), TestFlushPresenceQueue_ConcurrentDirectPresenceOrdersLast(), BroadcastAllEvent, ChannelEvent, ClientError, Event, ExcludeSenderEvent, SequencedDMEvent (+4 more) ### Community 227 - "NewTopicRateLimiter" Cohesion: 0.24 @@ -1393,12 +1399,12 @@ Cohesion: 0.17 Nodes (11): categories, correctness, perf, suspicious, ignorePatterns, public, rules, no-map-spread (+3 more) ### Community 230 - "message.go" -Cohesion: 0.18 -Nodes (12): MessageService, sanitizeContent(), sanitizePass(), SanitizeText(), sanitizeToFixpoint(), FuzzSanitizeContent(), TestSanitizeContent_EntitySmugglingBlocked(), TestSanitizeContent_PlainTextRoundTrip() (+4 more) +Cohesion: 0.09 +Nodes (21): AttachmentInfo, ReactionUser, MessageService, Store, requireChannelWritable(), RequireDMNotBlocked(), MessageService, MessageService (+13 more) ### Community 231 - "e2e/dm-system.spec.ts" -Cohesion: 0.23 -Nodes (10): MOCK_DM_CHANNELS, MOCK_READY_WITH_DMS, mockTauriSessionWithDms(), navigateToMainPageWithDms(), MOCK_AUTH_OK, MOCK_CHANNELS, MOCK_ROLES, submitLogin() (+2 more) +Cohesion: 0.20 +Nodes (13): MOCK_DM_CHANNELS, MOCK_READY_WITH_DMS, mockTauriSessionWithDms(), navigateToMainPageWithDms(), emitWsEvent(), emitWsMessage(), MOCK_AUTH_OK, MOCK_CHANNELS (+5 more) ### Community 232 - "Queries" Cohesion: 0.21 @@ -1424,21 +1430,21 @@ Nodes (12): voice_camera (Client -> Server), voice_config (Server -> Client, dir Cohesion: 0.17 Nodes (12): Choose Your Setup Path, Client Connection Notes, If Remote Users Cannot Connect, Next Steps, Option A: Prebuilt binaries (recommended), Option B: Docker (Linux server), Option C: Build from source, Optional: enable the GIF picker (+4 more) -### Community 238 - "readPump" -Cohesion: 0.22 -Nodes (7): TestWritePump_DrainsQueuedFramesAfterCloseSend(), Client, Hub, TestWritePump_DrainsAllNormalBeforeAnyLow(), readPump(), writePump(), TestHandleReconnect_HandshakeWriteFailure_TearsDownOnlyOnce() +### Community 238 - "OwnCord — Test Audit" +Cohesion: 0.18 +Nodes (11): 1. Method, 2. Findings, 3. Measured baselines (diff against these next time), 4. Bugs surfaced by the tests, 5. Refuted candidates (do not re-raise), 6. Backlog, Client (`vitest run --coverage`), Go — cross-package (`go test -coverpkg=./... ./...`) (+3 more) -### Community 239 - "mockTauriFullSessionWithVoice" -Cohesion: 0.25 -Nodes (6): joinVoiceChannelByName(), mockTauriFullSessionWithVoice(), mockTauriFullSessionWithVoiceFailure(), voiceJoinFailureHandler(), NOTE: These tests do NOT exercise real LiveKit/WebRTC connections., VOICE_STATE_EVENT +### Community 239 - "newBackupFileDB" +Cohesion: 0.35 +Nodes (10): newBackupFileDB(), TestBackupToSafe_ErrorKeepsPreexistingFile(), TestBackupToSafe_RejectsDoubleQuote(), TestBackupToSafe_RejectsNullByte(), TestBackupToSafe_RejectsPathOutsideRoot(), TestBackupToSafe_RejectsSemicolon(), TestBackupToSafe_RejectsSingleQuote(), TestBackupToSafe_RejectsSQLComment() (+2 more) ### Community 240 - "index.mjs" Cohesion: 0.23 Nodes (6): collectLogs(), httpsAgent(), REPO_ROOT, request(), safeParse(), server -### Community 241 - "countingReadStateStore" -Cohesion: 0.18 -Nodes (5): failingMembersStore, sync/atomic.Int64, Store, countingReadStateStore, countingStore +### Community 241 - "TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates" +Cohesion: 0.33 +Nodes (3): failingMembersStore, TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates(), Store ### Community 242 - "bughunt.harness.mjs" Cohesion: 0.18 @@ -1460,17 +1466,17 @@ Nodes (11): Audit, Cache and fan-out, Channel Permission Overrides, DELETE /admi Cohesion: 0.18 Nodes (11): DELETE /admin/api/users/{id}/sessions, Errors, GET /admin/api/stats, GET /admin/api/users, PATCH /admin/api/users/{id}, Request, Response 200 OK, Response 200 OK (+3 more) -### Community 247 - "deep-link.ts" -Cohesion: 0.22 -Nodes (11): initDeepLinks(), InviteLink, linkSegments(), log, MessageLink, parseIdSegment(), parseInviteLink(), parseMessageLink() (+3 more) +### Community 247 - ".DeleteAccount" +Cohesion: 0.33 +Nodes (7): database/sql.Tx, database/sql.TxOptions, anonymiseUser(), deleteAccountAdminGuard(), deleteAccountCloseDMChannels(), deleteAccountDMChannels(), DB -### Community 248 - "EventSink" -Cohesion: 0.18 -Nodes (7): Broadcaster, TestEventDeliveryHasNoGuestPath(), EventSink, NewEventSink(), TestEventSink_Emit_DeliversToBroadcaster(), TestEventSink_Emit_NilBroadcaster_NoOp(), TestHub_SetPluginEventSink_NoOp() +### Community 248 - "scaledAuthLimit" +Cohesion: 0.27 +Nodes (7): scaledAuthLimit(), setAuthRateScale(), TestLoginRateLimit_Value(), TestPerUserFailureCapsStayUnscaled(), TestRateLimiterCleanupHorizon_CoversMaxSlowMode(), TestScaledAuthLimit_NeverBelowOne(), TestSetAuthRateScale_ClampsMultiplier() ### Community 249 - "handleChatCommandV2" -Cohesion: 0.18 -Nodes (13): getCommandConstructor(), TestCanPluginBroadcast_NilServiceFailsClosed(), TestChatCommandConstructor_Errors(), TestHandleChatCommandV2_NoRegistry(), TestHandleVoiceJoinV2_SignalsJoin(), TestHandleVoiceLeaveV2_RateLimited(), TestHandleVoiceLeaveV2_SignalsLeave(), TestVoiceJoinConstructor_Errors() (+5 more) +Cohesion: 0.15 +Nodes (18): getCommandConstructor(), TestCanPluginBroadcast_NilServiceFailsClosed(), TestChatCommandConstructor_Errors(), TestHandleChatCommandV2_NoRegistry(), TestHandleVoiceJoinV2_SignalsJoin(), TestHandleVoiceLeaveV2_RateLimited(), TestHandleVoiceLeaveV2_SignalsLeave(), TestVoiceJoinConstructor_Errors() (+10 more) ### Community 250 - "slashFS" Cohesion: 0.29 @@ -1480,25 +1486,25 @@ Nodes (4): slashFS, failReadDirFS, io/fs.File, toSlashPath() Cohesion: 0.20 Nodes (9): 1. Hunt, 2. Gate (human), 3. Fix, 4. Verify the fixes independently — REQUIRED, Composing the batch, Running the bughunt pipeline, Security findings, Testing the workflows themselves (+1 more) -### Community 252 - "OverlayManagers.ts" -Cohesion: 0.07 -Nodes (34): MessageListComponent, findChannelById(), hasMessageJumpHandler(), log, MessageJumpHandler, setMessageJumpHandler(), showToast(), ChannelController (+26 more) +### Community 252 - "MainPage.ts" +Cohesion: 0.03 +Nodes (82): appendModerationSection(), showUserVolumeMenu(), VoiceModMenuOptions, IncomingCallBannerComponent, closeActiveLightbox(), clearReactionUsersCache(), setReactionUsersFetcher(), applyConnectionStatus() (+74 more) ### Community 253 - "reconnectAfterCertAccept" Cohesion: 0.31 Nodes (3): CertReconnectRouter, CertReconnectWs, reconnectAfterCertAccept() ### Community 254 - "VoiceTopic" -Cohesion: 0.23 -Nodes (11): TestVoiceTopic(), VoiceTopic(), Client, Hub, setupVoiceRoom(), TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers(), TestRegisterNow_ReelectsKeyHolderWhenReplacedClientLeavesVoice(), TestRegisterNow_ResumeRestoresVoiceTopicAndE2EEKey() (+3 more) +Cohesion: 0.14 +Nodes (16): TestEmitEvents_DirectPresenceDropsQueuedEntry(), NewPubSub(), TestUserTopic(), TestVoiceTopic(), topicFor(), UserTopic(), VoiceTopic(), Client (+8 more) ### Community 255 - "emoji-voicemod.parity.spec.ts" -Cohesion: 0.24 -Nodes (8): CapturedCall, getCapturedCalls(), mockSessionWithCustomEmoji(), mockVoiceSessionWithoutModPermission(), SEEDED_CUSTOM_EMOJI, waitForCapturedCall(), emitWsMessageAndWait(), voiceWsHandlers() +Cohesion: 0.19 +Nodes (10): CapturedCall, getCapturedCalls(), mockSessionWithCustomEmoji(), mockVoiceSessionWithoutModPermission(), SEEDED_CUSTOM_EMOJI, waitForCapturedCall(), emitWsMessageAndWait(), mockTauriFullSessionWithVoice() (+2 more) ### Community 256 - "voice-e2ee-verify.spec.ts" -Cohesion: 0.22 -Nodes (6): MOCK_CHANNELS_WITH_CATEGORIES, MOCK_VOICE_STATE, emitPeerAnnounce(), mockE2EEVoiceSession(), PeerCrypto, voiceJoinWithTokenHandler() +Cohesion: 0.18 +Nodes (7): joinVoiceChannelByName(), MOCK_CHANNELS_WITH_CATEGORIES, MOCK_VOICE_STATE, emitPeerAnnounce(), mockE2EEVoiceSession(), PeerCrypto, voiceJoinWithTokenHandler() ### Community 257 - "include" Cohesion: 0.20 @@ -1513,7 +1519,7 @@ Cohesion: 0.20 Nodes (10): Custom Emoji, DELETE /api/v1/emoji/{id}, Errors, Errors, GET /api/v1/emoji, GET /api/v1/emoji/{id}/image, POST /api/v1/emoji, Response 200 OK (+2 more) ### Community 260 - "OwnCord — Test-Coverage Audit" -Cohesion: 0.18 +Cohesion: 0.20 Nodes (10): 1. How coverage was measured (and why the CI number is wrong), 2. Finding closure status, 3. Measured baselines (diff against these next time), 4. Two bugs surfaced by writing the tests, 5. CI gates after this pass, 6. Backlog, Client (`npx vitest run --coverage`), Go — cross-package (`make cover-all`) (+2 more) ### Community 261 - "OriginAcceptOptions" @@ -1521,12 +1527,12 @@ Cohesion: 0.31 Nodes (8): github.com/coder/websocket.AcceptOptions, OriginAcceptOptions(), TestOriginAcceptOptions_EmptyList(), TestOriginAcceptOptions_ExplicitOrigins(), TestOriginAcceptOptions_MixedWithWildcard(), TestOriginAcceptOptions_NilList(), TestOriginAcceptOptions_ReturnsAcceptOptions(), TestOriginAcceptOptions_WildcardEnablesInsecureSkipVerify() ### Community 262 - "EventRingBuffer" -Cohesion: 0.13 -Nodes (4): github.com/owncord/server/syncutil.RWMutex, Hub, eventEntry, EventRingBuffer +Cohesion: 0.31 +Nodes (3): github.com/owncord/server/syncutil.RWMutex, eventEntry, EventRingBuffer -### Community 263 - "IsUniqueConstraintError" -Cohesion: 0.21 -Nodes (11): IsUniqueConstraintError(), TestIsUniqueConstraintError_CaseSensitive(), TestIsUniqueConstraintError_MatchesSQLiteMessage(), TestIsUniqueConstraintError_NilError(), TestIsUniqueConstraintError_UnrelatedError(), TestIsUniqueConstraintError_WrappedSQLiteError(), TestSentinelErrors_AreDistinct(), TestSentinelErrors_DoubleWrapped() (+3 more) +### Community 263 - ".UpdateUserProfile" +Cohesion: 0.28 +Nodes (4): UpdateUserCustomStatusParams, UpdateUserPasswordParams, UpdateUserProfileParams, Queries ### Community 264 - "newTokenTestDB" Cohesion: 0.51 @@ -1549,7 +1555,7 @@ Cohesion: 0.22 Nodes (8): CI wiring (`.github/workflows/ci.yml`), Current status: 291 web tests, 291 passed (100%), E2E Test Status — 2026-08-05, Environment notes for local runs, History (dispositions of the old contents of this file), Known issues (open), Resolved (2026-08-04 remediation), Suite inventory ### Community 269 - "render-ledger.mjs" -Cohesion: 0.43 +Cohesion: 0.27 Nodes (6): main(), render(), selftest(), SEV_RANK, VALID_STATUS, validate() ### Community 270 - "MountGIFRoutes" @@ -1568,9 +1574,9 @@ Nodes (4): CreateAttachmentParams, GetAttachmentByIDRow, GetAttachmentWithChanne Cohesion: 0.67 Nodes (6): equalSets(), idSet(), seedVisibilityUser(), sortedKeys(), TestChannelVisibility_RESTWSAgreement(), TestChannelVisibility_UserOverrideAgreement() -### Community 274 - "seed.go" -Cohesion: 0.27 -Nodes (10): seedChannel, seedMessage, seedUser, createChannels(), createDMConversation(), createMessages(), createUsers(), messageExists() (+2 more) +### Community 274 - "OwnCord — Comprehensive Project Audit" +Cohesion: 0.22 +Nodes (9): 8. Plugin System Governance, 9. Prioritized Top-10 Action List, Bonus (quick wins), CRITICAL Issues, Finding closure status (maintained; last updated 2026-07-20), OwnCord — Comprehensive Project Audit, Plugin Architecture, Strengths (+1 more) ### Community 275 - "Finish the V2 Dispatch Migration (backlog item 11) — Design" Cohesion: 0.22 @@ -1585,8 +1591,8 @@ Cohesion: 0.22 Nodes (9): chat_bulk_deleted (Server -> Client, broadcast), chat_delete (Client -> Server), chat_deleted (Server -> Client, broadcast), chat_edit (Client -> Server), chat_edited (Server -> Client, broadcast), chat_message (Server -> Client, broadcast), Chat Messages, chat_send (Client -> Server) (+1 more) ### Community 279 - "DMChannelInfo" -Cohesion: 0.29 -Nodes (7): createDMResponse, listDMsResponse, TestNewDMChannelInfo_EmptyRecipientsIsNotNil(), TestNewDMChannelInfo_ExcludesViewerAndPicksRecipient(), DMChannelInfo, DMUser, NewDMChannelInfo() +Cohesion: 0.11 +Nodes (15): createDMResponse, listDMsResponse, MemberSummary, TestNewDMChannelInfo_EmptyRecipientsIsNotNil(), TestNewDMChannelInfo_ExcludesViewerAndPicksRecipient(), DMChannelInfo, DMUser, NewDMChannelInfo() (+7 more) ### Community 280 - "RingBuffer" Cohesion: 0.32 @@ -1632,25 +1638,21 @@ Nodes (8): emoji_update (Server -> Client, broadcast), member_ban (Server -> Cli Cohesion: 0.57 Nodes (7): message, schema, header(), main(), renderGo(), renderTS(), validate() -### Community 292 - ".finishVoiceLeave" -Cohesion: 0.50 -Nodes (3): Client, Hub, leaveVoiceChannelWithRetry() - ### Community 294 - "Environments, Activation Setup, and Handoff-Doc Mode" Cohesion: 0.29 Nodes (6): Compaction behaviour, Environments, Activation Setup, and Handoff-Doc Mode, Handoff-doc analysis (when one arrives), Handoff-doc mode (no persistent storage), Recommended activation setup, User-facing documentation -### Community 295 - "handlePingV2" -Cohesion: 0.60 -Nodes (4): TestPingV2_HappyPath_ReturnsPongReply(), TestPingV2_NoEvents(), TestPingV2_RateLimited_ReturnsEmpty(), handlePingV2() +### Community 295 - "newBlockService" +Cohesion: 0.46 +Nodes (7): newBlockService(), TestBlockService_BlockUser(), TestBlockService_BlockUser_Idempotent(), TestBlockService_BlockUser_Rejections(), TestBlockService_ListBlocked(), TestBlockService_UnblockUser(), TestBlockService_UnblockUser_Rejections() ### Community 296 - "capabilities-scope.test.ts" Cohesion: 0.29 Nodes (4): Permission, permissions, ScopedPermission, ScopeEntry -### Community 297 - "savePref" -Cohesion: 0.38 -Nodes (5): THEME_KEYS, ThemeName, THEMES, savePref(), STORAGE_PREFIX +### Community 297 - "handleDiagnosticsConnectivity" +Cohesion: 0.48 +Nodes (6): clientDiag, diagnosticsResponse, serverDiag, voiceDiag, handleDiagnosticsConnectivity(), isPrivateIP() ### Community 298 - "GET /admin/api/updates" Cohesion: 0.29 @@ -1692,9 +1694,13 @@ Nodes (6): openFileDB(), seedChannelAndUser(), TestFilePool_ConcurrentReadsAndWr Cohesion: 0.29 Nodes (6): Build command, Building the WASM, hello plugin, Manifest, Prerequisites, Tests -### Community 309 - "window-state.ts" -Cohesion: 0.22 -Nodes (7): initWindowState(), isRectOnScreen(), log, MonitorRect, WindowRect, h, PRIMARY +### Community 308 - "TestAdminAPI_PatchChannel_ArchiveCleansVoice" +Cohesion: 0.29 +Nodes (6): TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(), TestAdminAPI_PatchChannel_ArchiveCleansVoice(), TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(), TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(), SetCreateChannelPostCommitHook(), SetPatchChannelPostCommitHook() + +### Community 309 - "default_verify_schemes" +Cohesion: 0.67 +Nodes (3): default_verify_schemes(), Vec, SignatureScheme ### Community 310 - "Tauri HTTP Capability Narrowing — Design" Cohesion: 0.22 @@ -1716,6 +1722,14 @@ Nodes (5): Approval policy, Comprehensive Review (scheduled or fallback), Constr Cohesion: 0.33 Nodes (4): CertTofuPayload, FIRST_USE, MISMATCH, MISMATCH_LIVE_HOST +### Community 318 - "navigateToMainPageReady" +Cohesion: 0.18 +Nodes (5): mockTauriFullSessionWithVoiceFailure(), navigateToMainPageReady(), voiceJoinFailureHandler(), mockUpdaterSession(), NOTE: These tests do NOT exercise real LiveKit/WebRTC connections. + +### Community 319 - "4. Dependencies & Supply Chain" +Cohesion: 0.29 +Nodes (7): 4. Dependencies & Supply Chain, Go Modules — 30 direct deps, ALL exact-pinned ✅, Known Vulnerabilities, License Compliance, Lockfile Status, npm — 13 production deps, ALL floating (^) ⚠️, Overall Posture: **MODERATE RISK** (Go excellent, npm floating) + ### Community 320 - "tsconfig.build.json" Cohesion: 0.33 Nodes (5): exclude, extends, include, src, ./tsconfig.json @@ -1740,21 +1754,25 @@ Nodes (6): First-Run Setup, GET /admin/api/setup/status, POST /admin/api/setup, Cohesion: 0.33 Nodes (6): GET /api/v1/livekit/health, LiveKit Endpoints, /livekit/* (Reverse Proxy), POST /api/v1/livekit/webhook, Response 200 OK, Response 503 Service Unavailable -### Community 326 - "types.go" -Cohesion: 0.38 -Nodes (6): adminContextKey, adminMeResponse, adminUserResponse, toAdminUserResponse(), toAdminUserResponseFromUser(), UserWithRole +### Community 326 - "5. Test Coverage & Quality" +Cohesion: 0.29 +Nodes (7): 5. Test Coverage & Quality, Go — Critical Coverage Gaps, Go — Package Coverage, Go — Test Quality: GOOD, TypeScript — E2E Coverage: EXCELLENT, TypeScript — Test Files, TypeScript — Unit Coverage: MINIMAL (<10%) ### Community 327 - "Tailscale Guide (Zero-Config Remote Access)" -Cohesion: 0.11 -Nodes (14): D4a — Connect, authenticate, replay, D4b — Broadcast fanout and backpressure, D4c — Typed command dispatch, WebSocket / Real-time Engine, Benefits, Setup, Tailscale Guide (Zero-Config Remote Access), TLS Recommendation (+6 more) +Cohesion: 0.33 +Nodes (6): Benefits, Setup, Tailscale Guide (Zero-Config Remote Access), TLS Recommendation, Voice/Video with Tailscale, Why Tailscale -### Community 334 - "TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit" +### Community 329 - "perm_grid_test.go" Cohesion: 0.48 -Nodes (4): Store, TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit(), TestHandlePresenceUpdate_CustomStatusWriteFailureKeepsStoredText(), faultyStore +Nodes (6): overrideMatrixBits(), permGridBits(), TestAdminPanelOverrideMatrixCoversChannelScopedBits(), TestAdminPanelOverrideMatrixHasSingleDefinedBits(), TestAdminPanelPermGridCoversEveryPermissionBit(), TestAdminPanelPermGridHasNoDuplicateOrCompositeBits() -### Community 335 - "New" -Cohesion: 0.38 -Nodes (6): NewDMService(), TestDMService_CreateDM_AllowsLapsedTemporaryBan(), TestDMService_CreateDM_RefusesBannedRecipient(), TestDMService_CreateGroupDM_RefusesBannedRecipient(), Store, New() +### Community 334 - "admin-static-channel-perms.test.ts" +Cohesion: 0.33 +Nodes (4): ADMIN_HTML, ADMIN_HTML_PATH, ADMIN_HTML_SOURCE, FetchCall + +### Community 335 - "NewDMService" +Cohesion: 0.53 +Nodes (5): NewDMService(), TestDMService_CreateDM_AllowsLapsedTemporaryBan(), TestDMService_CreateDM_RefusesBannedRecipient(), TestDMService_CreateGroupDM_RefusesBannedRecipient(), TestDMService_CreateGroupDM_SurvivesCancelledPostCommitRead() ### Community 336 - "GET /api/v1/client-update/{target}/{current_version}" Cohesion: 0.40 @@ -1772,13 +1790,13 @@ Nodes (5): voice_e2ee_announce (Client -> Server), voice_e2ee_announce (Server - Cohesion: 0.40 Nodes (4): Additional Context, Alternatives Considered, Problem, Proposed Solution -### Community 341 - "buildMetricsRouter" -Cohesion: 0.53 -Nodes (5): buildMetricsRouter(), TestHandleMetrics_AdminIPRestrict_AllowsAdmin(), TestHandleMetrics_AdminIPRestrict_BlocksNonAdmin(), TestHandleMetrics_ReturnsExpectedFields(), TestHandleMetrics_WithoutLiveKitHealthCheck() +### Community 341 - "isAddrInUse" +Cohesion: 0.40 +Nodes (4): isAddrInUse(), TestIsAddrInUse_RealBindConflict(), TestIsAddrInUse_Table(), TestServeWithBindRetry() -### Community 342 - "RunningInContainer" +### Community 342 - "MetricsSources" Cohesion: 0.50 -Nodes (3): RunningInContainer(), TestRunningInContainer_BareMetalDefault(), TestRunningInContainer_EnvSemantics() +Nodes (4): EventPersisterMetrics, MetricsSources, ServerMetrics, database/sql.DBStats ### Community 344 - "Permission-Middleware Consolidation (audit finding A-2026-07-16) — Design" Cohesion: 0.33 @@ -1852,29 +1870,73 @@ Nodes (3): Presence, presence (Server -> Client, broadcast), presence_update (Cl Cohesion: 0.67 Nodes (3): Transport Layer, Transport Limits, WebSocket Endpoint -### Community 449 - "RateLimiter" -Cohesion: 0.21 -Nodes (9): entry, lockoutEntry, LockoutPersister, rateLimiterShard, time.Duration, RateLimiter, NewPersistentRateLimiter(), newRateLimiter() (+1 more) +### Community 414 - "updater.test.ts" +Cohesion: 0.40 +Nodes (4): invoke, listen, relaunch, unlisten + +### Community 415 - "1. Architecture" +Cohesion: 0.40 +Nodes (5): 1. Architecture, Anti-patterns, Communication Patterns, Dependency Direction, Layer Map + +### Community 419 - "6. CI/CD & DevEx" +Cohesion: 0.40 +Nodes (5): 6. CI/CD & DevEx, Build Reproducibility, Gaps, Linting Enforcement, Pipeline Gates + +### Community 420 - "7. Observability" +Cohesion: 0.40 +Nodes (5): 7. Observability, Client-Side: LIMITED ⚠️, Error Surfacing: GOOD ✅, Logging: STRONG ✅, Metrics & Tracing: PRESENT (build-tag gated) + +### Community 421 - "syscall.SysProcAttr" +Cohesion: 0.40 +Nodes (3): syscall.SysProcAttr, liveKitSysProcAttr(), liveKitSysProcAttr() + +### Community 422 - "Security Policy" +Cohesion: 0.40 +Nodes (4): Hardening documentation, Reporting a vulnerability, Security Policy, Supported versions + +### Community 423 - "erroringMembersStore" +Cohesion: 0.40 +Nodes (3): Store, erroringMembersStore, rendezvousListStore + +### Community 449 - "Capture" +Cohesion: 0.50 +Nodes (3): Capture(), panicWithSecretArgs(), TestCaptureOmitsArguments() + +### Community 537 - "D7 — Module map" +Cohesion: 0.50 +Nodes (4): Client Architecture (Tauri), D7 — Module map, Key mechanisms, Quality tooling + +### Community 538 - "WebSocket / Real-time Engine" +Cohesion: 0.50 +Nodes (4): D4a — Connect, authenticate, replay, D4b — Broadcast fanout and backpressure, D4c — Typed command dispatch, WebSocket / Real-time Engine + +### Community 539 - "Audit 2026-07-19 — Maintainer Decisions" +Cohesion: 0.50 +Nodes (4): Audit 2026-07-19 — Maintainer Decisions, Decisions, Explicitly not decided here, Suggested sequencing + +### Community 541 - "owncord-introspect (MCP dev tool)" +Cohesion: 0.50 +Nodes (3): Full documentation, owncord-introspect (MCP dev tool), Quickstart ## Knowledge Gaps -- **1822 isolated node(s):** `DispatcherCleanup`, `ClientMessageTypeValue`, `MessageTypeValue`, `ServerMessageTypeValue`, `RemoteVideoCallback` (+1817 more) +- **1881 isolated node(s):** `here`, `scenarios`, `FOUR_FILES`, `PROVE_FAIL`, `meta` (+1876 more) These have ≤1 connection - possible missing edges or undocumented components. -- **98 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **100 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `DB` connect `DB` to `testing.T`, `openMigratedMemory`, `context.Context`, `buildChannelRouter`, `newTokenTestDB`, `middleware_and_spawn_test.go`, `waitRegistered`, `NewAdminAPI`, `MountGIFRoutes`, `TestMigrate_UpgradeFromMigration019PreservesData`, `newChannelTestAPI`, `net/http.HandlerFunc`, `handleLogStream`, `handleSetup`, `seed.go`, `newPurgeService`, `buildDMRouter`, `newHandlerHub`, `newAuthTestDB`, `newMigratedTestDB`, `newTestPermService`, `newHarvestVoiceDB`, `drainChanTimeout`, `User`, `database/sql.Result`, `newUploadTestDB`, `writeJSON`, `newAdminTestDB`, `HashToken`, `NewChecker`, `middleware_test.go`, `Result`, `handlers_backup.go`, `NewRouter`, `net/http.Handler`, `openFileDB`, `newTestDB`, `newServeHub`, `NewMessageService`, `seedMemberUser`, `postJSONWithToken`, `newVoiceTestDB`, `message_reactions_test.go`, `run`, `itoa`, `newUserSvc`, `plugins_handler_test.go`, `types.go`, `Role`, `.handleFreshConnect`, `newEmojiService`, `handleVoiceTokenRefreshV2`, `messages_test.go`, `openAdminTestDB`, `db/db.go`, `newMentionFixture`, `Channel`, `Migrate`, `Hub`, `newTestMessageService`, `NewTestClient`, `TestChannelVisibility_RESTWSAgreement`, `emoji_handler_test.go`, `handleCreateEmoji`, `doRequest`, `newTestRoleService`, `MigrateFS`, `newOverrideFixture`, `errDMChannelIDsStore`, `NewRegistry`, `voice_moderation_test.go`, `TestHandleVoiceCameraV2_RefusedWhenScreenshareSlotFull`, `countingReadStateStore`, `newRoleCRUDService`, `Checker`, `gif_handler_test.go`?** - _High betweenness centrality (0.037) - this node is a cross-community bridge._ +- **Why does `DB` connect `DB` to `testing.T`, `openMigratedMemory`, `context.Context`, `buildChannelRouter`, `waitRegistered`, `NewAdminAPI`, `writeErr`, `NewTestClient`, `newHandlerHub`, `drainChanTimeout`, `buildDMRouter`, `newAuthTestDB`, `newMigratedTestDB`, `EventPersister`, `Hub`, `database/sql.Result`, `net/http.Handler`, `net/http.HandlerFunc`, `newAdminTestDB`, `HashToken`, `plugin/registry_test.go`, `newDeafenRaceDB`, `NewChecker`, `middleware_test.go`, `Result`, `NewRouter`, `profileCreateToken`, `RateLimiter`, `newTestDB`, `newServeHub`, `seedMemberUser`, `postJSONWithToken`, `newVoiceTestDB`, `doRequest`, `Role`, `openAdminTestDB`, `newMentionFixture`, `Channel`, `Migrate`, `Hub`, `newTestMessageService`, `deps.go`, `emoji_handler_test.go`, `DB`, `openMemory`, `PermissionService`, `joinVoice`, `NewHandler`, `newRoleCRUDService`, `VoiceDeps`, `gif_handler_test.go`, `User`, `middleware_and_spawn_test.go`, `newPurgeService`, `handleVoiceTokenRefreshV2`, `newChannelTestAPI`, `handleLogStream`, `handleSetup`, `newHarvestVoiceDB`, `seedChannel`, `AuditWriter`, `net/http.Request`, `handleRestoreBackup`, `newDMFixture`, `Server/main.go`, `newUserSvc`, `plugins_handler_test.go`, `github.com/coder/websocket.Conn`, `itoa`, `newTestRoleService`, `newBackupFileDB`, `.DeleteAccount`, `newTokenTestDB`, `MountGIFRoutes`, `TestMigrate_UpgradeFromMigration019PreservesData`, `TestChannelVisibility_RESTWSAgreement`, `DMChannelInfo`, `newBlockService`, `openFileDB`, `TestHandleVoiceCameraV2_RefusedWhenScreenshareSlotFull`, `errDMChannelIDsStore`?** + _High betweenness centrality (0.019) - this node is a cross-community bridge._ - **Why does `installTestPlugin()` connect `newMigratedTestDB` to `testing.T`, `context.Context`?** _High betweenness centrality (0.016) - this node is a cross-community bridge._ -- **Why does `mustSetSetting()` connect `chdirTemp` to `testing.T`, `context.Context`?** +- **Why does `mustSetSetting()` connect `NewHandler` to `testing.T`, `context.Context`?** _High betweenness centrality (0.016) - this node is a cross-community bridge._ -- **Are the 281 inferred relationships involving `waitRegistered()` (e.g. with `TestChannelFocus_AdminBypassesDeny()` and `TestChannelFocus_AllowedByDefault()`) actually correct?** - _`waitRegistered()` has 281 INFERRED edges - model-reasoned connections that need verification._ +- **Are the 284 inferred relationships involving `waitRegistered()` (e.g. with `TestChannelFocus_AdminBypassesDeny()` and `TestChannelFocus_AllowedByDefault()`) actually correct?** + _`waitRegistered()` has 284 INFERRED edges - model-reasoned connections that need verification._ - **Are the 2 inferred relationships involving `NewTestClientWithUser()` (e.g. with `TestRefreshChannelVisibility_ReconnectDuringFanOutActsOnLiveClient()` and `TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady()`) actually correct?** _`NewTestClientWithUser()` has 2 INFERRED edges - model-reasoned connections that need verification._ -- **What connects `DispatcherCleanup`, `ClientMessageTypeValue`, `MessageTypeValue` to the rest of the system?** - _1822 weakly-connected nodes found - possible documentation gaps or missing edges._ +- **What connects `here`, `scenarios`, `FOUR_FILES` to the rest of the system?** + _1881 weakly-connected nodes found - possible documentation gaps or missing edges._ - **Should `members.store.ts` be split into smaller, more focused modules?** - _Cohesion score 0.03450134770889488 - nodes in this community are weakly interconnected._ \ No newline at end of file + _Cohesion score 0.030991735537190084 - nodes in this community are weakly interconnected._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index 16a8a20e..b2e96c81 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@
-
529 nodes · 1449 edges · 529 communities
+
550 nodes · 1469 edges · 550 communities