mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" --
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = "<span>hello</span>";
|
||||
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 = "<span>hello</span>";
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>((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<string>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>): 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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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://<host>/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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
Generated
+109
-88
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+1
-1
File diff suppressed because one or more lines are too long
Generated
+578
-516
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
File diff suppressed because one or more lines are too long
Generated
+201450
-179951
File diff suppressed because it is too large
Load Diff
Generated
+603
-388
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user