test: close measured test-coverage gaps across server, client and Rust

Audits what actually has tests, then closes the gaps it found. Full write-up
with before/after numbers in docs/audit-test-coverage-2026-07-25.md.

Measurement first: `go test ./... -coverprofile` (what CI runs) instruments
each package only for itself, so code exercised through another package's
tests reads as uncovered — `service` reported 36.7% against a real 85%. All
analysis here uses -coverpkg=./..., and both views now have Makefile targets.

Features that had zero coverage at every layer:
- user blocking (db + service + the /api/v1/blocks routes)
- auth lockout persistence — the DB round-trip that survives a restart
- plugin install/enable/disable/uninstall and the plugin KV namespace
- event replay bounds (GetMaxEventSeq, PruneEventsOlderThan)
- LiveKit participant_joined webhook (replayed-token guard), the room-service
  client, and proxyWebSocket/copyWS
- ws_proxy.rs and livekit_proxy.rs — pure helpers extracted, matching the
  existing tofu.rs pattern, so cert-pin and header-injection checks are testable

Gaps that were hidden rather than absent:
- Server/admin reported 0.3% coverage with 307 tests passing. TestSpawnDetached_*
  re-execs the test binary; the child inherited GOCOVERDIR and the parent's
  stdout, clobbering the profile and printing "[no tests to run]". Now 71.4%,
  and CI's uploaded artifact is correct.
- vitest.config.ts excluded 2.2k LOC unexplained, including two files that
  already had tests. Trimmed to three entries, each justified inline.
- api.HandleLiveKitHealthForTest re-implemented the handler it claimed to
  expose, so eight call sites tested a copy. Added a hook to the real one.

Two bugs found and pinned rather than silently patched: logctx.WithGroup nests
req_id under the group, and drag-reorder.ts takes one listener ref per channel
but releases one per sidebar, so the count never reaches zero.

Coverage: client 92.93% -> 94.87% statements (3371 -> 3572 tests) even after
un-excluding hidden files; Rust 47 -> 74 tests; Go zero-coverage functions
~70 -> 21, with plugin 61->77%, admin 67->86%, db 76->84%, service 85->91%.

Verified: go vet, all four build-tag variants, go test -race, -tags deadlock,
vitest --coverage, cargo test --lib, cargo clippy --all-targets, playwright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEETs3Vh6sAHHb1jMBL75g
This commit is contained in:
Claude
2026-07-25 14:47:21 +00:00
parent b3cacebd92
commit 0918f859a0
37 changed files with 7179 additions and 72 deletions
+83 -5
View File
@@ -171,6 +171,87 @@ jobs:
path: Client/tauri-client/coverage/
retention-days: 7
# Rust unit tests used to live inside tauri-build, which only runs on PRs to
# main — so #[cfg(test)] code never ran on pushes or on PRs to dev, and could
# rot for a whole release cycle. This job runs them on every event. Clippy is
# run with --all-targets here (tauri-build's lib-only clippy skips test code).
rust-tests:
name: Rust Unit Tests
runs-on: ubuntu-22.04
defaults:
run:
working-directory: Client/tauri-client/src-tauri/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libasound2-dev \
libssl-dev \
librsvg2-dev
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with:
workspaces: Client/tauri-client/src-tauri
- name: Clippy lint (including test targets)
run: cargo clippy --all-targets -- -D warnings
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. Non-blocking for now
# (backlog #10): the suite has never run in CI, so it gets a soak period
# before it becomes a gate. Remove continue-on-error to promote it.
# The native config (playwright.config.native.ts) is deliberately not wired
# up — it needs a real server and a built desktop binary.
client-e2e:
name: Client E2E (Playwright, non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test --config=playwright.config.ts
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: |
Client/tauri-client/playwright-report/
Client/tauri-client/test-results/
retention-days: 7
server-docker-build:
name: Server Docker Build (verify)
runs-on: ubuntu-latest
@@ -270,11 +351,8 @@ jobs:
working-directory: Client/tauri-client/src-tauri/
run: cargo clippy -- -D warnings
# Without this, #[cfg(test)] code is never compiled or run in CI
# (clippy above skips test targets), so Rust unit tests would rot.
- name: Rust unit tests
working-directory: Client/tauri-client/src-tauri/
run: cargo test --lib
# Rust unit tests moved to the standalone `rust-tests` job so they run on
# every event, not just PRs to main.
- name: Security audit (Rust dependencies)
working-directory: Client/tauri-client/src-tauri/
@@ -68,6 +68,78 @@ impl LiveKitProxyState {
use crate::tofu;
// ---------------------------------------------------------------------------
// Pure helpers
//
// Split out of the command / connection-handling functions so the parts that
// decide what reaches the remote server — host validation, header rewriting and
// TLS server-name selection — are reachable from unit tests without a Tauri
// runtime or a live socket.
// ---------------------------------------------------------------------------
/// Reject `remote_host` values that could inject headers or are not plausible
/// host:port strings.
pub(crate) fn validate_remote_host(remote_host: &str) -> Result<(), String> {
// CRLF or NUL would let a caller append arbitrary headers in the rewriting
// logic below.
if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') {
return Err("remote_host contains invalid characters".into());
}
// Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !remote_host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
{
return Err("remote_host contains unexpected characters".into());
}
Ok(())
}
/// Rewrite the `Host` and `Origin` headers of a proxied HTTP request so the
/// remote server's WebSocket origin check accepts a connection that the
/// LiveKit SDK opened against `127.0.0.1`.
///
/// Every other line is passed through byte-for-byte, including the request
/// line and the trailing blank line that terminates the header block.
pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String {
let mut modified = String::with_capacity(request.len() + 128);
for (i, line) in request.split("\r\n").enumerate() {
if i > 0 {
modified.push_str("\r\n");
}
let lower = line.to_lowercase();
if lower.starts_with("host:") {
modified.push_str("Host: ");
modified.push_str(remote_host);
} else if lower.starts_with("origin:") {
modified.push_str("Origin: https://");
modified.push_str(remote_host);
} else {
modified.push_str(line);
}
}
modified
}
/// Extract the TLS server name from a `host[:port]` string.
///
/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and
/// an IP literal becomes `ServerName::IpAddress` rather than a DNS name, since
/// rustls will not accept an address as a DNS name.
pub(crate) fn parse_server_name(remote_host: &str) -> Result<ServerName<'static>, String> {
// Default to port 443 (standard HTTPS) when no port is specified — the
// server is typically behind a reverse proxy (nginx) on the standard port.
let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443"));
let hostname = raw_hostname.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = hostname.parse::<IpAddr>() {
Ok(ServerName::IpAddress(ip.into()))
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))
}
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -84,15 +156,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
state: tauri::State<'_, LiveKitProxyState>,
remote_host: String,
) -> Result<u16, String> {
// Reject remote_host values containing CRLF or null bytes to prevent
// HTTP header injection in the proxy's header rewriting logic.
if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') {
return Err("remote_host contains invalid characters".into());
}
// Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !remote_host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
return Err("remote_host contains unexpected characters".into());
}
validate_remote_host(&remote_host)?;
let mut inner = state.inner.lock().await;
@@ -266,22 +330,7 @@ async fn handle_connection(
// ── 2. Rewrite Host and Origin headers ───────────────────────────────
let request = String::from_utf8_lossy(&buf);
let mut modified = String::with_capacity(buf.len() + 128);
for (i, line) in request.split("\r\n").enumerate() {
if i > 0 {
modified.push_str("\r\n");
}
let lower = line.to_lowercase();
if lower.starts_with("host:") {
modified.push_str("Host: ");
modified.push_str(remote_host);
} else if lower.starts_with("origin:") {
modified.push_str("Origin: https://");
modified.push_str(remote_host);
} else {
modified.push_str(line);
}
}
let modified = rewrite_proxy_headers(&request, remote_host);
// ── 3. Connect to remote over TLS ────────────────────────────────────
let tls_config = rustls::ClientConfig::builder()
@@ -293,20 +342,7 @@ async fn handle_connection(
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
// Parse hostname (strip brackets for IPv6, e.g. "[::1]:8443").
// Default to port 443 (standard HTTPS) when no port is specified — the
// server is typically behind a reverse proxy (nginx) on the standard port.
let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443"));
let hostname = raw_hostname
.trim_start_matches('[')
.trim_end_matches(']');
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let server_name = parse_server_name(remote_host)?;
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = TcpStream::connect(remote_host).await?;
@@ -330,3 +366,196 @@ async fn handle_connection(
}
// cert_store_key is covered by unit tests in the shared `tofu` module.
// ---------------------------------------------------------------------------
// Tests (pure logic only — no Tauri runtime or live socket required)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ── validate_remote_host ────────────────────────────────────────────────
#[test]
fn accepts_plain_hostnames_and_ports() {
for host in [
"example.com",
"example.com:8443",
"sub.domain.example.com",
"my-server.example.com:443",
"127.0.0.1:8443",
"[::1]:8443",
"localhost",
] {
assert!(validate_remote_host(host).is_ok(), "should accept {host}");
}
}
#[test]
fn rejects_crlf_injection() {
// The classic header-injection payload: everything after the CRLF
// would land in the rewritten request as attacker-chosen headers.
for host in [
"example.com\r\nX-Injected: 1",
"example.com\nX-Injected: 1",
"example.com\r",
] {
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
}
}
#[test]
fn rejects_nul_bytes() {
assert!(validate_remote_host("example.com\0").is_err());
}
#[test]
fn rejects_unexpected_characters() {
for host in [
"example.com/path",
"user@example.com",
"example.com?q=1",
"example com",
"exa mple.com:443",
"example.com;evil",
] {
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
}
}
#[test]
fn accepts_empty_host() {
// Empty passes the character checks; the subsequent TCP connect is what
// fails. Pinned so a future tightening is a deliberate change.
assert!(validate_remote_host("").is_ok());
}
// ── rewrite_proxy_headers ───────────────────────────────────────────────
#[test]
fn rewrites_host_and_origin() {
let request = "GET /rtc HTTP/1.1\r\n\
Host: 127.0.0.1:54321\r\n\
Origin: http://127.0.0.1:54321\r\n\
Upgrade: websocket\r\n\r\n";
let got = rewrite_proxy_headers(request, "chat.example.com:8443");
assert!(got.contains("Host: chat.example.com:8443"));
assert!(got.contains("Origin: https://chat.example.com:8443"));
assert!(!got.contains("127.0.0.1:54321"));
}
#[test]
fn preserves_the_request_line_and_other_headers() {
let request = "GET /rtc?access_token=abc HTTP/1.1\r\n\
Host: 127.0.0.1:1\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZQ==\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
// The token lives in the query string; losing it turns every voice
// join into an auth failure.
assert!(got.starts_with("GET /rtc?access_token=abc HTTP/1.1\r\n"));
assert!(got.contains("Upgrade: websocket"));
assert!(got.contains("Connection: Upgrade"));
assert!(got.contains("Sec-WebSocket-Key: dGhlIHNhbXBsZQ=="));
}
#[test]
fn matches_header_names_case_insensitively() {
let request = "GET / HTTP/1.1\r\nhOsT: 127.0.0.1\r\nORIGIN: http://x\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
assert!(got.contains("Host: example.com"));
assert!(got.contains("Origin: https://example.com"));
assert!(!got.contains("hOsT"));
assert!(!got.contains("ORIGIN"));
}
#[test]
fn preserves_the_terminating_blank_line() {
let request = "GET / HTTP/1.1\r\nHost: x\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
// Without the trailing CRLFCRLF the remote server keeps waiting for
// more headers and the handshake hangs.
assert!(got.ends_with("\r\n\r\n"), "got: {got:?}");
}
#[test]
fn does_not_rewrite_headers_that_merely_contain_host_or_origin() {
let request =
"GET / HTTP/1.1\r\nHost: x\r\nX-Forwarded-Host: keep.me\r\nReferer: http://o\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
assert!(got.contains("X-Forwarded-Host: keep.me"));
assert!(got.contains("Referer: http://o"));
}
#[test]
fn adds_no_headers_when_none_are_present() {
let request = "GET / HTTP/1.1\r\nUpgrade: websocket\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
// The rewriter only replaces; it never synthesises a Host header.
assert_eq!(got, request);
}
#[test]
fn rewrites_every_occurrence() {
let request = "GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n";
let got = rewrite_proxy_headers(request, "example.com");
assert_eq!(got.matches("Host: example.com").count(), 2);
}
// ── parse_server_name ───────────────────────────────────────────────────
#[test]
fn parses_a_dns_name_without_a_port() {
let got = parse_server_name("example.com").expect("should parse");
assert!(matches!(got, ServerName::DnsName(_)));
}
#[test]
fn parses_a_dns_name_with_a_port() {
let got = parse_server_name("example.com:8443").expect("should parse");
match got {
ServerName::DnsName(d) => assert_eq!(d.as_ref(), "example.com"),
other => panic!("expected DnsName, got {other:?}"),
}
}
#[test]
fn parses_an_ipv4_literal_as_an_address() {
// rustls rejects an IP supplied as a DNS name, so the branch matters.
let got = parse_server_name("127.0.0.1:8443").expect("should parse");
assert!(matches!(got, ServerName::IpAddress(_)));
}
#[test]
fn parses_a_bracketed_ipv6_literal_as_an_address() {
let got = parse_server_name("[::1]:8443").expect("should parse");
assert!(matches!(got, ServerName::IpAddress(_)));
}
#[test]
fn parses_a_bare_ipv4_literal() {
let got = parse_server_name("10.0.0.5").expect("should parse");
assert!(matches!(got, ServerName::IpAddress(_)));
}
#[test]
fn rejects_an_invalid_dns_name() {
assert!(parse_server_name("not a hostname").is_err());
}
}
+1 -1
View File
@@ -545,7 +545,7 @@ mod tests {
assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}");
assert_eq!(
vk_to_keycode(vk),
Some(keycode.clone()),
Some(keycode),
"vk_to_keycode failed for vk={vk:#04x}"
);
}
+96 -10
View File
@@ -282,6 +282,23 @@ pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), Strin
Ok(())
}
/// Whether `fingerprint` is a SHA-256 digest in colon-hex form:
/// `XX:XX:XX:...`, 32 hex pairs separated by colons (95 chars total).
///
/// Split out of `accept_cert_fingerprint` so the format check — the guard on
/// the only code path that writes a cert pin — is reachable from unit tests
/// without a Tauri runtime.
pub(crate) fn is_valid_cert_fingerprint(fingerprint: &str) -> bool {
fingerprint.len() == 95
&& fingerprint.bytes().enumerate().all(|(i, b)| {
if (i + 1) % 3 == 0 {
b == b':'
} else {
b.is_ascii_hexdigit()
}
})
}
/// Accept a certificate fingerprint for a host — the ONLY path that writes a pin.
/// Called after the user acknowledges a first-use or cert-mismatch prompt.
#[tauri::command]
@@ -294,16 +311,7 @@ pub fn accept_cert_fingerprint<R: Runtime>(
return Err("host and fingerprint must not be empty".into());
}
// Validate SHA-256 colon-hex format: XX:XX:XX:... (32 pairs = 95 chars)
let valid = fingerprint.len() == 95
&& fingerprint.bytes().enumerate().all(|(i, b)| {
if (i + 1) % 3 == 0 {
b == b':'
} else {
b.is_ascii_hexdigit()
}
});
if !valid {
if !is_valid_cert_fingerprint(&fingerprint) {
return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into());
}
@@ -338,3 +346,81 @@ pub fn accept_cert_fingerprint<R: Runtime>(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests (pure logic only — no Tauri runtime required)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// A well-formed SHA-256 colon-hex fingerprint (32 pairs, 95 chars).
const VALID: &str = "e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:\
27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55";
#[test]
fn valid_fingerprint_is_accepted() {
assert_eq!(VALID.len(), 95, "test constant must be 95 chars");
assert!(is_valid_cert_fingerprint(VALID));
}
#[test]
fn uppercase_hex_is_accepted() {
assert!(is_valid_cert_fingerprint(&VALID.to_uppercase()));
}
#[test]
fn empty_fingerprint_is_rejected() {
assert!(!is_valid_cert_fingerprint(""));
}
#[test]
fn wrong_length_is_rejected() {
// One pair short, and one pair too many.
assert!(!is_valid_cert_fingerprint(&VALID[..92]));
assert!(!is_valid_cert_fingerprint(&format!("{VALID}:00")));
}
#[test]
fn non_hex_characters_are_rejected() {
// 'z' is not a hex digit; length still 95.
let bad = VALID.replacen('e', "z", 1);
assert_eq!(bad.len(), 95);
assert!(!is_valid_cert_fingerprint(&bad));
}
#[test]
fn wrong_separator_is_rejected() {
// Dashes instead of colons — same length, same hex digits.
let bad = VALID.replace(':', "-");
assert_eq!(bad.len(), 95);
assert!(!is_valid_cert_fingerprint(&bad));
}
#[test]
fn misplaced_separator_is_rejected() {
// Swap a colon with an adjacent hex digit so the colons land off-grid
// while the length and character set stay legal.
let mut bytes = VALID.as_bytes().to_vec();
bytes.swap(2, 3);
let bad = String::from_utf8(bytes).unwrap();
assert_eq!(bad.len(), 95);
assert!(!is_valid_cert_fingerprint(&bad));
}
#[test]
fn whitespace_padding_is_rejected() {
// A pasted fingerprint with surrounding whitespace must not slip
// through — it would be stored verbatim and never match a real cert.
assert!(!is_valid_cert_fingerprint(&format!(" {VALID}")));
assert!(!is_valid_cert_fingerprint(&format!("{VALID} ")));
}
#[test]
fn non_ascii_of_correct_byte_length_is_rejected() {
// Guards the byte-indexed validator against multi-byte input.
let bad = format!("é{}", &VALID[..93]);
assert!(!is_valid_cert_fingerprint(&bad));
}
}
@@ -0,0 +1,180 @@
/**
* Tests for src/lib/credentials.ts.
*
* This module was excluded from coverage in vitest.config.ts and only ever
* `vi.mock`ed by other suites, so none of it had ever been executed under test.
* It is the JS half of the OS keychain integration ("stay signed in"), and every
* function is written to fail soft — returning false/null rather than throwing —
* which is precisely why silent breakage here goes unnoticed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args) as unknown,
}));
const { saveCredential, loadCredential, deleteCredential } = await import("@lib/credentials");
beforeEach(() => {
invoke.mockReset().mockResolvedValue(undefined);
});
// ── saveCredential ─────────────────────────────────────────────────────────
describe("saveCredential", () => {
it("forwards host, username and token to the Rust command", async () => {
await expect(saveCredential("h.example", "alice", "tok")).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith("save_credential", {
host: "h.example",
username: "alice",
token: "tok",
password: null,
});
});
it("passes an explicit password through", async () => {
await saveCredential("h.example", "alice", "tok", "s3cret");
expect(invoke).toHaveBeenCalledWith("save_credential", {
host: "h.example",
username: "alice",
token: "tok",
password: "s3cret",
});
});
it("normalises a missing password to null rather than undefined", async () => {
await saveCredential("h.example", "alice", "tok");
// undefined would be dropped from the IPC payload and the Rust side would
// see a missing argument instead of an explicit "no password".
const args = invoke.mock.calls[0]?.[1] as Record<string, unknown>;
expect(args.password).toBeNull();
expect("password" in args).toBe(true);
});
it("returns false when the command rejects", async () => {
invoke.mockRejectedValue(new Error("keychain locked"));
await expect(saveCredential("h.example", "alice", "tok")).resolves.toBe(false);
});
});
// ── loadCredential ─────────────────────────────────────────────────────────
describe("loadCredential", () => {
it("returns the stored username and token", async () => {
invoke.mockResolvedValue({ username: "alice", token: "tok" });
await expect(loadCredential("h.example")).resolves.toEqual({
username: "alice",
token: "tok",
});
expect(invoke).toHaveBeenCalledWith("load_credential", { host: "h.example" });
});
it("drops any extra fields the backend returns", async () => {
// The Rust side deliberately stopped returning the password over IPC; if it
// ever regresses, the password must not make it into the JS heap.
invoke.mockResolvedValue({ username: "alice", token: "tok", password: "leaked" });
const got = await loadCredential("h.example");
expect(got).toEqual({ username: "alice", token: "tok" });
expect(got).not.toHaveProperty("password");
});
it("returns null when nothing is stored", async () => {
invoke.mockResolvedValue(null);
await expect(loadCredential("h.example")).resolves.toBeNull();
});
it.each([
["a string", "not-an-object"],
["a number", 42],
["an object with no username", { token: "tok" }],
["an object with no token", { username: "alice" }],
["a non-string username", { username: 1, token: "tok" }],
["a non-string token", { username: "alice", token: 1 }],
])("returns null for a malformed result (%s)", async (_label, result) => {
invoke.mockResolvedValue(result);
await expect(loadCredential("h.example")).resolves.toBeNull();
});
it("returns null when the command rejects", async () => {
invoke.mockRejectedValue(new Error("keychain locked"));
await expect(loadCredential("h.example")).resolves.toBeNull();
});
});
// ── deleteCredential ───────────────────────────────────────────────────────
describe("deleteCredential", () => {
it("forwards the host and reports success", async () => {
await expect(deleteCredential("h.example")).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith("delete_credential", { host: "h.example" });
});
it("returns false when the command rejects", async () => {
invoke.mockRejectedValue(new Error("no such entry"));
await expect(deleteCredential("h.example")).resolves.toBe(false);
});
});
// ── non-Tauri fallback ─────────────────────────────────────────────────────
describe("outside Tauri", () => {
/**
* Re-imports credentials.ts with the Tauri core module supplying no `invoke`
* export, so getInvoke() resolves to a falsy value and every function takes
* its "Tauri not available" early return. That is the same branch a genuine
* import failure lands on (a plain browser, or a test that never stubs the
* module), reached deterministically.
*
* The branch matters because the connect screen calls loadCredential
* unconditionally on mount: it must return null, not reject.
*/
async function importWithoutInvoke(): Promise<typeof import("@lib/credentials")> {
vi.resetModules();
vi.doMock("@tauri-apps/api/core", () => ({}));
return import("@lib/credentials");
}
afterEach(() => {
// credentials.ts imports the Tauri core module lazily, inside getInvoke —
// so the mock has to stay in place until after the call under test, not
// just until the module import.
vi.doUnmock("@tauri-apps/api/core");
vi.resetModules();
});
it("saveCredential reports failure instead of throwing", async () => {
const { saveCredential: save } = await importWithoutInvoke();
await expect(save("h.example", "alice", "tok")).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
});
it("loadCredential returns null instead of throwing", async () => {
const { loadCredential: load } = await importWithoutInvoke();
await expect(load("h.example")).resolves.toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
it("deleteCredential reports failure instead of throwing", async () => {
const { deleteCredential: del } = await importWithoutInvoke();
await expect(del("h.example")).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,166 @@
/**
* Tests for initDeepLinks in src/lib/deep-link.ts.
*
* deep-link.ts sat at 44% statements: the existing deep-link.test.ts covers the
* pure parser, but initDeepLinks — the part that actually runs at startup and
* decides whether an owncord:// invite reaches the register form — had no
* coverage at all. Its failure modes are all silent by design (every step is
* wrapped in try/catch so a missing plugin cannot break boot), which is exactly
* why the branches need pinning: a regression here does not throw, it just
* quietly stops honouring invite links.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const register = vi.fn();
const getCurrent = vi.fn();
const onOpenUrl = vi.fn();
vi.mock("@tauri-apps/plugin-deep-link", () => ({
register: (...args: unknown[]) => register(...args) as unknown,
getCurrent: (...args: unknown[]) => getCurrent(...args) as unknown,
onOpenUrl: (...args: unknown[]) => onOpenUrl(...args) as unknown,
}));
const { initDeepLinks, parseInviteLink } = await import("@lib/deep-link");
beforeEach(() => {
register.mockReset().mockResolvedValue(undefined);
getCurrent.mockReset().mockResolvedValue(null);
onOpenUrl.mockReset().mockResolvedValue(undefined);
});
describe("initDeepLinks", () => {
it("registers the owncord scheme", async () => {
await initDeepLinks(vi.fn());
expect(register).toHaveBeenCalledWith("owncord");
});
it("continues when scheme registration is rejected", async () => {
// Registration fails when the scheme is already claimed, or on platforms
// that do not permit runtime registration. Neither must stop the listener
// from being wired.
register.mockRejectedValue(new Error("already registered"));
await initDeepLinks(vi.fn());
expect(onOpenUrl).toHaveBeenCalled();
});
it("dispatches a cold-start invite from getCurrent", async () => {
getCurrent.mockResolvedValue(["owncord://invite/COLD1"]);
const onInvite = vi.fn();
await initDeepLinks(onInvite);
expect(onInvite).toHaveBeenCalledWith("COLD1", undefined);
});
it("passes the host through when the link carries one", async () => {
getCurrent.mockResolvedValue(["owncord://invite/COLD2?host=chat.example.com:8443"]);
const onInvite = vi.fn();
await initDeepLinks(onInvite);
expect(onInvite).toHaveBeenCalledWith("COLD2", "chat.example.com:8443");
});
it("tolerates a null cold-start result", async () => {
getCurrent.mockResolvedValue(null);
const onInvite = vi.fn();
await initDeepLinks(onInvite);
expect(onInvite).not.toHaveBeenCalled();
expect(onOpenUrl).toHaveBeenCalled();
});
it("dispatches every link in a batch", async () => {
getCurrent.mockResolvedValue(["owncord://invite/A", "owncord://invite/B"]);
const onInvite = vi.fn();
await initDeepLinks(onInvite);
expect(onInvite).toHaveBeenCalledTimes(2);
expect(onInvite).toHaveBeenNthCalledWith(1, "A", undefined);
expect(onInvite).toHaveBeenNthCalledWith(2, "B", undefined);
});
it("ignores unrecognized links but still handles valid ones in the batch", async () => {
getCurrent.mockResolvedValue([
"https://example.com/invite/NOPE",
"owncord://",
"owncord://invite/GOOD",
]);
const onInvite = vi.fn();
await initDeepLinks(onInvite);
expect(onInvite).toHaveBeenCalledTimes(1);
expect(onInvite).toHaveBeenCalledWith("GOOD", undefined);
});
it("dispatches warm-launch invites through the onOpenUrl callback", async () => {
const onInvite = vi.fn();
await initDeepLinks(onInvite);
// The plugin hands the app a batch of URLs while it is already running.
const handler = onOpenUrl.mock.calls[0]?.[0] as (urls: readonly string[]) => void;
expect(handler).toBeTypeOf("function");
handler(["owncord://invite/WARM?host=h.example"]);
expect(onInvite).toHaveBeenCalledWith("WARM", "h.example");
});
it("swallows a getCurrent rejection without wiring a listener", async () => {
getCurrent.mockRejectedValue(new Error("ipc down"));
const onInvite = vi.fn();
await expect(initDeepLinks(onInvite)).resolves.toBeUndefined();
expect(onInvite).not.toHaveBeenCalled();
expect(onOpenUrl).not.toHaveBeenCalled();
});
it("swallows an onOpenUrl rejection", async () => {
onOpenUrl.mockRejectedValue(new Error("no listener slot"));
await expect(initDeepLinks(vi.fn())).resolves.toBeUndefined();
});
});
describe("parseInviteLink malformed percent-encoding", () => {
it("falls back to the raw segment when decoding throws", () => {
// A lone "%" is not valid percent-encoding; decodeURIComponent throws a
// URIError and the raw segment is used instead of losing the invite.
expect(parseInviteLink("owncord://invite/100%")).toEqual({ code: "100%" });
});
it("drops a code that decodes to whitespace only", () => {
expect(parseInviteLink("owncord://invite/%20%20")).toBeNull();
});
it("ignores an empty host parameter", () => {
expect(parseInviteLink("owncord://invite/ABC?host=")).toEqual({ code: "ABC" });
});
it("trims a padded host parameter", () => {
expect(parseInviteLink("owncord://invite/ABC?host=%20h.example%20")).toEqual({
code: "ABC",
host: "h.example",
});
});
it("ignores unrelated query parameters", () => {
expect(parseInviteLink("owncord://invite/ABC?ref=twitter")).toEqual({ code: "ABC" });
});
it("tolerates multiple trailing slashes", () => {
expect(parseInviteLink("owncord://invite/ABC///")).toEqual({ code: "ABC" });
});
it("ignores extra path segments after the code", () => {
expect(parseInviteLink("owncord://invite/ABC/extra")).toEqual({ code: "ABC" });
});
});
@@ -0,0 +1,486 @@
/**
* Tests for src/components/channel-sidebar/drag-reorder.ts.
*
* This module was the least-covered file in the client (38.8% statements) and
* had no test of its own — the reorder index arithmetic, the admin-only gate,
* the 5px drag threshold and the listener ref-counting were all unverified.
* Off-by-one errors in the insert index silently reorder the wrong pair of
* channels for every member of the server.
*/
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import {
attachDragHandlers,
ensureGlobalDragListeners,
releaseGlobalDragListeners,
} from "@components/channel-sidebar/drag-reorder";
import type { ChannelReorderData } from "@components/ChannelSidebar";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { UserWithRole } from "@lib/types";
// ── helpers ────────────────────────────────────────────────────────────────
function makeUser(role: string): UserWithRole {
return { id: 1, username: "tester", avatar: null, role };
}
function signIn(role: string): void {
authStore.setState(() => ({
token: "t",
user: makeUser(role),
serverName: "s",
motd: "",
isAuthenticated: true,
}));
}
function makeCh(id: number, position: number, name = `ch-${id}`): Channel {
return {
id,
name,
type: "text",
category: null,
position,
unreadCount: 0,
lastMessageId: null,
canSend: true,
};
}
interface Rig {
container: HTMLElement;
items: Map<number, HTMLElement>;
channels: Channel[];
onReorder: ReturnType<typeof vi.fn>;
abort: AbortController;
}
/** Builds a container with one 20px-tall row per channel, stacked vertically. */
function buildRig(channels: Channel[]): Rig {
const container = document.createElement("div");
document.body.appendChild(container);
const items = new Map<number, HTMLElement>();
const onReorder = vi.fn();
const abort = new AbortController();
channels.forEach((ch, idx) => {
const el = document.createElement("div");
container.appendChild(el);
// jsdom does not lay out, so stub the geometry the module reads.
const top = idx * 20;
el.getBoundingClientRect = () =>
({
top,
bottom: top + 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: top,
toJSON: () => ({}),
}) as DOMRect;
attachDragHandlers(el, ch, container, channels, abort.signal, onReorder);
items.set(ch.id, el);
});
return { container, items, channels, onReorder, abort };
}
/** Row `idx` spans y = idx*20 .. idx*20+20; its midpoint is +10. */
function yInRow(idx: number, half: "top" | "bottom"): number {
return idx * 20 + (half === "top" ? 4 : 16);
}
function mouse(type: string, clientX: number, clientY: number): MouseEvent {
return new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true });
}
/** Drives a full drag of `fromId` onto the given half of row `toIdx`. */
function drag(rig: Rig, fromId: number, toIdx: number, half: "top" | "bottom"): void {
const source = rig.items.get(fromId);
if (source === undefined) throw new Error(`no row for channel ${fromId}`);
const fromIdx = rig.channels.findIndex((c) => c.id === fromId);
source.dispatchEvent(mouse("mousedown", 0, yInRow(fromIdx, "top")));
// Past the 5px threshold so the drag activates.
source.dispatchEvent(mouse("mousemove", 0, yInRow(fromIdx, "top") + 20));
document.dispatchEvent(mouse("mouseup", 0, yInRow(toIdx, half)));
}
beforeEach(() => {
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
document.body.className = "";
document.body.innerHTML = "";
});
afterEach(() => {
// End any drag still in flight. `activeDrag` is module-level state and
// releaseGlobalDragListeners() only clears it when handed the owning
// container, so without this a half-finished drag leaks into the next test
// and blocks it from starting one. Re-arm the global handlers first: a test
// that drained the ref-count has aborted them, and the mouseup below needs a
// live listener to do the clearing.
ensureGlobalDragListeners();
document.dispatchEvent(mouse("mouseup", 0, -1000));
// Drain the ref-count so the document listeners do not leak between tests.
for (let i = 0; i < 50; i++) releaseGlobalDragListeners();
document.body.innerHTML = "";
document.body.className = "";
});
// ── permission gate ────────────────────────────────────────────────────────
describe("attachDragHandlers permission gate", () => {
it.each([
["owner", true],
["admin", true],
["Owner", true],
["ADMIN", true],
["moderator", false],
["member", false],
["", false],
])("role %s → draggable %s", (role, draggable) => {
signIn(role);
const rig = buildRig([makeCh(1, 0)]);
const el = rig.items.get(1);
expect(el?.classList.contains("channel-draggable")).toBe(draggable);
});
it("does nothing when no user is signed in", () => {
const rig = buildRig([makeCh(1, 0)]);
expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false);
});
it("does nothing when no onReorderChannel callback is supplied", () => {
signIn("owner");
const container = document.createElement("div");
document.body.appendChild(container);
const el = document.createElement("div");
container.appendChild(el);
attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], new AbortController().signal);
expect(el.classList.contains("channel-draggable")).toBe(false);
expect(el.dataset.dragChannelId).toBeUndefined();
});
it("stamps the channel id for hit-testing when permitted", () => {
signIn("admin");
const rig = buildRig([makeCh(7, 0)]);
expect(rig.items.get(7)?.dataset.dragChannelId).toBe("7");
});
});
// ── drag threshold ─────────────────────────────────────────────────────────
describe("drag activation threshold", () => {
it("does not start a drag below the 5px threshold", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const el = rig.items.get(1);
el?.dispatchEvent(mouse("mousedown", 0, 4));
el?.dispatchEvent(mouse("mousemove", 2, 6)); // dx+dy = 4
expect(el?.classList.contains("dragging")).toBe(false);
expect(document.body.classList.contains("channel-reordering")).toBe(false);
});
it("starts a drag once movement exceeds the threshold", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const el = rig.items.get(1);
el?.dispatchEvent(mouse("mousedown", 0, 4));
el?.dispatchEvent(mouse("mousemove", 3, 10)); // dx+dy = 9
expect(el?.classList.contains("dragging")).toBe(true);
expect(document.body.classList.contains("channel-reordering")).toBe(true);
});
it("ignores non-left mouse buttons", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const el = rig.items.get(1);
el?.dispatchEvent(new MouseEvent("mousedown", { clientX: 0, clientY: 4, button: 2 }));
el?.dispatchEvent(mouse("mousemove", 0, 40));
expect(el?.classList.contains("dragging")).toBe(false);
});
it("a mouseup before the threshold cancels the pending drag", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const el = rig.items.get(1);
el?.dispatchEvent(mouse("mousedown", 0, 4));
el?.dispatchEvent(mouse("mouseup", 0, 4));
el?.dispatchEvent(mouse("mousemove", 0, 60));
expect(el?.classList.contains("dragging")).toBe(false);
});
});
// ── reorder arithmetic ─────────────────────────────────────────────────────
describe("reorder index arithmetic", () => {
it("dropping on the top half inserts before the target", () => {
signIn("owner");
// Rows: idx0=ch1, idx1=ch2, idx2=ch3.
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
expect(rig.onReorder).toHaveBeenCalledTimes(1);
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 2 });
});
it("dropping on the bottom half inserts after the target", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
drag(rig, 1, 1, "bottom"); // ch1 after ch2 → [2, 1, 3]
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
});
it("only reports channels whose position actually changed", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2), makeCh(4, 3)]);
drag(rig, 1, 1, "bottom"); // → [2, 1, 3, 4]; ch3 and ch4 keep their slots
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
const touched = reorders.map((r) => r.channelId).sort((a, b) => a - b);
expect(touched).toEqual([1, 2]);
});
it("updates the channels store immediately (optimistic reorder)", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
channelsStore.setState(() => ({
channels: new Map([
[1, makeCh(1, 0)],
[2, makeCh(2, 1)],
]),
activeChannelId: null,
roles: [],
}));
drag(rig, 1, 1, "bottom"); // → [2, 1]
expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
});
it("does not fire when dropped on itself", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
drag(rig, 1, 0, "bottom"); // row 0 is ch1 itself
expect(rig.onReorder).not.toHaveBeenCalled();
});
it("does not fire when dropped outside any row", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const el = rig.items.get(1);
el?.dispatchEvent(mouse("mousedown", 0, 4));
el?.dispatchEvent(mouse("mousemove", 0, 30));
document.dispatchEvent(mouse("mouseup", 0, 9999)); // below every row
expect(rig.onReorder).not.toHaveBeenCalled();
});
it("does not fire when the drag lands back in its original slot", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
// ch1 dropped on the top half of ch2 → insert before ch2 → [1, 2, 3],
// which is the order it already had.
drag(rig, 1, 1, "top");
expect(rig.onReorder).not.toHaveBeenCalled();
});
it("clears visual state on drop", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
drag(rig, 1, 1, "bottom");
expect(rig.items.get(1)?.classList.contains("dragging")).toBe(false);
expect(document.body.classList.contains("channel-reordering")).toBe(false);
expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
});
});
// ── drop indicator ─────────────────────────────────────────────────────────
describe("drop indicator", () => {
it("marks the hovered row and never the dragged row", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
const source = rig.items.get(1);
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
// Hovering the dragged row itself must not show a drop target.
document.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top")));
expect(rig.items.get(1)?.classList.contains("channel-drop-indicator")).toBe(false);
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(false);
});
it("moves the indicator as the cursor moves between rows", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
const source = rig.items.get(1);
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(true);
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(false);
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
});
it("global handlers no-op when no drag is active", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top")));
expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
expect(rig.onReorder).not.toHaveBeenCalled();
});
});
// ── listener lifecycle ─────────────────────────────────────────────────────
describe("global listener ref-counting", () => {
it("keeps listeners alive until every ref is released", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); // 2 channels → 2 refs
ensureGlobalDragListeners(); // a second sidebar → 3
releaseGlobalDragListeners(); // → 2
// Refs still outstanding, so a drag must still work.
drag(rig, 1, 1, "bottom");
expect(rig.onReorder).toHaveBeenCalledTimes(1);
});
/**
* KNOWN BUG — the ref-count is asymmetric.
*
* `attachDragHandlers` calls `ensureGlobalDragListeners()` once per *channel
* element* (ChannelSidebar.ts:431, inside the per-channel render), but
* `releaseGlobalDragListeners` is called once per *sidebar destroy*
* (ChannelSidebar.ts:703). So a sidebar showing N channels takes N refs and
* gives back 1, and every re-render takes N more. In production the count
* never returns to 0, the AbortController never fires, and the two document
* listeners (plus the `activeDrag` closure they capture) live for the rest
* of the process.
*
* The leak is currently benign — both handlers return immediately while
* `activeDrag` is null, and re-registration is guarded — so this test pins
* the behaviour as it actually is rather than as the doc comment describes
* it ("only the last destroy tears them down"). If the ref-counting is
* fixed so one release per sidebar suffices, this test should change with it.
*/
it("takes one ref per channel, so a single release does not tear down", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); // 2 refs, not 1
releaseGlobalDragListeners(); // → 1, not 0
drag(rig, 1, 1, "bottom");
expect(rig.onReorder).toHaveBeenCalledTimes(1);
});
it("tears listeners down once the ref-count reaches zero", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
releaseGlobalDragListeners();
releaseGlobalDragListeners(); // drops to 0 → AbortController fires
drag(rig, 1, 1, "bottom");
expect(rig.onReorder).not.toHaveBeenCalled();
});
it("clears an in-flight drag owned by the released container", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const source = rig.items.get(1);
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
expect(source?.classList.contains("dragging")).toBe(true);
releaseGlobalDragListeners(rig.container);
// A sidebar destroyed mid-drag must not leave the row stuck in the
// dragging state or the body stuck in reorder mode.
expect(source?.classList.contains("dragging")).toBe(false);
expect(document.body.classList.contains("channel-reordering")).toBe(false);
});
it("leaves a drag owned by a different container alone", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
const source = rig.items.get(1);
const otherContainer = document.createElement("div");
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
ensureGlobalDragListeners(); // keep the count above zero
releaseGlobalDragListeners(otherContainer);
expect(source?.classList.contains("dragging")).toBe(true);
});
it("release is safe to over-call", () => {
expect(() => {
releaseGlobalDragListeners();
releaseGlobalDragListeners();
releaseGlobalDragListeners();
}).not.toThrow();
});
});
/** Collapses reorder data into a channelId → newPosition map. */
function positionsOf(reorders: readonly ChannelReorderData[]): Record<number, number> {
const out: Record<number, number> = {};
for (const r of reorders) out[r.channelId] = r.newPosition;
return out;
}
@@ -0,0 +1,419 @@
/**
* Tests for src/lib/livekitDiagnostics.ts (was 30.4% statements, no test file).
*
* This module is what an operator reads when a voice call fails to connect, so
* a diagnostic that silently returns nothing — or worse, throws while poking at
* LiveKit's private `engine` field — makes a hard bug harder. Every function
* here reaches through `room as unknown as Record<string, unknown>` into
* internals that can disappear on a LiveKit upgrade, so the defensive paths
* matter more than the happy path.
*/
import { describe, expect, it, vi } from "vitest";
import { RoomEvent } from "livekit-client";
import type { Room } from "livekit-client";
import {
attachDiagnosticListeners,
buildSessionDebugInfo,
getIceConnectionState,
logIceConnectionInfo,
} from "@lib/livekitDiagnostics";
import type { SessionDebugDeps } from "@lib/livekitDiagnostics";
import type { AudioPipeline } from "@lib/audioPipeline";
import type { AudioElements } from "@lib/audioElements";
// ── fakes ──────────────────────────────────────────────────────────────────
/** A Room stub that records `on` registrations so they can be fired by name. */
function fakeRoom(extra: Record<string, unknown> = {}): {
room: Room;
handlers: Map<string, (...args: unknown[]) => void>;
} {
const handlers = new Map<string, (...args: unknown[]) => void>();
const room = {
on(event: string, cb: (...args: unknown[]) => void) {
handlers.set(event, cb);
return this;
},
...extra,
} as unknown as Room;
return { room, handlers };
}
function fakePeerConnection(over: Partial<RTCPeerConnection> = {}): RTCPeerConnection {
return {
iceConnectionState: "connected",
iceGatheringState: "complete",
connectionState: "connected",
signalingState: "stable",
getStats: vi.fn().mockResolvedValue(new Map()),
...over,
} as unknown as RTCPeerConnection;
}
/** Builds an engine object shaped the way the module expects to find it. */
function withEngine(subscriberPc?: RTCPeerConnection, publisherPc?: RTCPeerConnection): Room {
return {
engine: {
...(subscriberPc !== undefined ? { subscriber: { pc: subscriberPc } } : {}),
...(publisherPc !== undefined ? { publisher: { pc: publisherPc } } : {}),
},
} as unknown as Room;
}
function fakePipeline(over: Partial<AudioPipeline> = {}): AudioPipeline {
return {
isActive: true,
gainValue: 1,
ctxState: "running",
isVadGated: false,
inputGain: 1,
...over,
} as unknown as AudioPipeline;
}
function fakeElements(effectiveVolume = 1): AudioElements {
return { getEffectiveVolume: () => effectiveVolume } as unknown as AudioElements;
}
// ── attachDiagnosticListeners ──────────────────────────────────────────────
describe("attachDiagnosticListeners", () => {
it("registers every diagnostic room event", () => {
const { room, handlers } = fakeRoom();
attachDiagnosticListeners(room);
for (const event of [
RoomEvent.Reconnecting,
RoomEvent.Reconnected,
RoomEvent.SignalReconnecting,
RoomEvent.MediaDevicesError,
RoomEvent.ConnectionQualityChanged,
]) {
expect(handlers.has(event), `missing handler for ${event}`).toBe(true);
}
});
it("handlers run without throwing", () => {
const { room, handlers } = fakeRoom();
attachDiagnosticListeners(room);
expect(() => {
handlers.get(RoomEvent.Reconnecting)?.();
handlers.get(RoomEvent.Reconnected)?.();
handlers.get(RoomEvent.SignalReconnecting)?.();
handlers.get(RoomEvent.MediaDevicesError)?.(new Error("no mic"));
handlers.get(RoomEvent.ConnectionQualityChanged)?.("excellent", { isLocal: true });
handlers.get(RoomEvent.ConnectionQualityChanged)?.("poor", { isLocal: false });
}).not.toThrow();
});
});
// ── logIceConnectionInfo ───────────────────────────────────────────────────
describe("logIceConnectionInfo", () => {
it("is a no-op for a null room", () => {
expect(() => {
logIceConnectionInfo(null);
}).not.toThrow();
});
it("is a no-op when the engine is absent", () => {
expect(() => {
logIceConnectionInfo({} as unknown as Room);
}).not.toThrow();
});
it("is a no-op when the engine has neither peer connection", () => {
expect(() => {
logIceConnectionInfo({ engine: {} } as unknown as Room);
}).not.toThrow();
});
it("reads stats from both peer connections", () => {
const sub = fakePeerConnection();
const pub = fakePeerConnection();
logIceConnectionInfo(withEngine(sub, pub));
expect(sub.getStats).toHaveBeenCalled();
expect(pub.getStats).toHaveBeenCalled();
});
it("resolves the selected candidate pair from the stats report", async () => {
const stats = new Map<string, Record<string, unknown>>([
[
"pair1",
{
id: "pair1",
type: "candidate-pair",
state: "succeeded",
localCandidateId: "lc1",
remoteCandidateId: "rc1",
},
],
["lc1", { id: "lc1", type: "local-candidate", candidateType: "srflx", protocol: "udp" }],
["rc1", { id: "rc1", type: "remote-candidate", candidateType: "relay" }],
]);
const getStats = vi.fn().mockResolvedValue(stats);
logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never)));
await vi.waitFor(() => {
expect(getStats).toHaveBeenCalled();
});
});
it("tolerates candidate ids that resolve to nothing", async () => {
// A pair referencing candidates absent from the report leaves the types as
// "unknown" rather than throwing.
const stats = new Map<string, Record<string, unknown>>([
[
"pair1",
{
id: "pair1",
type: "candidate-pair",
state: "succeeded",
localCandidateId: "missing",
remoteCandidateId: "also-missing",
},
],
]);
const getStats = vi.fn().mockResolvedValue(stats);
expect(() => {
logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never)));
}).not.toThrow();
await vi.waitFor(() => {
expect(getStats).toHaveBeenCalled();
});
});
it("swallows a rejected getStats", async () => {
const getStats = vi.fn().mockRejectedValue(new Error("pc closed"));
expect(() => {
logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never)));
}).not.toThrow();
await vi.waitFor(() => {
expect(getStats).toHaveBeenCalled();
});
});
it("swallows a throwing engine getter", () => {
const room = {
get engine(): unknown {
throw new Error("internals moved");
},
} as unknown as Room;
// The whole point of the try/catch: a LiveKit upgrade that renames or
// guards `engine` must not take the voice session down with it.
expect(() => {
logIceConnectionInfo(room);
}).not.toThrow();
});
});
// ── getIceConnectionState ──────────────────────────────────────────────────
describe("getIceConnectionState", () => {
it("returns null for a null room", () => {
expect(getIceConnectionState(null)).toBeNull();
});
it("returns null when the engine is absent", () => {
expect(getIceConnectionState({} as unknown as Room)).toBeNull();
});
it("returns an empty object when the engine has no peer connections", () => {
expect(getIceConnectionState({ engine: {} } as unknown as Room)).toEqual({});
});
it("reports both peer connections", () => {
const got = getIceConnectionState(
withEngine(
fakePeerConnection({ iceConnectionState: "checking", connectionState: "connecting" }),
fakePeerConnection({ iceConnectionState: "connected", connectionState: "connected" }),
),
);
expect(got).toEqual({
subscriber: { iceConnectionState: "checking", connectionState: "connecting" },
publisher: { iceConnectionState: "connected", connectionState: "connected" },
});
});
it("reports only the peer connection that exists", () => {
const got = getIceConnectionState(withEngine(fakePeerConnection()));
expect(got).toHaveProperty("subscriber");
expect(got).not.toHaveProperty("publisher");
});
it("returns null when reaching into internals throws", () => {
const room = {
get engine(): unknown {
throw new Error("internals moved");
},
} as unknown as Room;
expect(getIceConnectionState(room)).toBeNull();
});
});
// ── buildSessionDebugInfo ──────────────────────────────────────────────────
describe("buildSessionDebugInfo", () => {
const baseDeps: Omit<SessionDebugDeps, "room"> = {
currentChannelId: 12,
outputVolumeMultiplier: 1.5,
audioPipeline: fakePipeline(),
audioElements: fakeElements(),
};
it("returns the minimal shape when there is no room", () => {
const got = buildSessionDebugInfo({ ...baseDeps, room: null });
expect(got).toEqual({ hasRoom: false, hasRNNoiseProcessor: false, currentChannelId: 12 });
});
it("summarises an active room", () => {
const room = {
name: "channel-12",
state: "connected",
remoteParticipants: new Map(),
localParticipant: {
identity: "user-1:tok",
trackPublications: new Map(),
getTrackPublication: () => undefined,
},
engine: {},
} as unknown as Room;
const got = buildSessionDebugInfo({ ...baseDeps, room });
expect(got.hasRoom).toBe(true);
expect(got.roomName).toBe("channel-12");
expect(got.roomState).toBe("connected");
expect(got.currentChannelId).toBe(12);
expect(got.outputVolumeMultiplier).toBe(1.5);
expect(got.localParticipant).toBe("user-1:tok");
expect(got.audioPipelineActive).toBe(true);
expect(got.audioPipelineGain).toBe(1);
expect(got.audioPipelineCtxState).toBe("running");
expect(got.vadGated).toBe(false);
expect(got.currentInputGain).toBe(1);
expect(got.hasRNNoiseProcessor).toBe(false);
});
it("reports hasRNNoiseProcessor when the mic track carries a processor", () => {
const room = {
name: "r",
state: "connected",
remoteParticipants: new Map(),
localParticipant: {
identity: "user-1",
trackPublications: new Map(),
getTrackPublication: () => ({ track: { getProcessor: () => ({ name: "rnnoise" }) } }),
},
engine: {},
} as unknown as Room;
expect(buildSessionDebugInfo({ ...baseDeps, room }).hasRNNoiseProcessor).toBe(true);
});
it("maps remote participants, their volumes and their tracks", () => {
const room = {
name: "r",
state: "connected",
remoteParticipants: new Map([
[
"user-7:tok",
{
identity: "user-7:tok",
getVolume: () => 0.8,
trackPublications: new Map([
[
"sid1",
{
trackSid: "sid1",
source: "microphone",
kind: "audio",
isSubscribed: true,
isEnabled: true,
},
],
]),
},
],
]),
localParticipant: {
identity: "user-1",
trackPublications: new Map(),
getTrackPublication: () => undefined,
},
engine: {},
} as unknown as Room;
const got = buildSessionDebugInfo({ ...baseDeps, room, audioElements: fakeElements(0.6) });
const remotes = got.remoteParticipants as Array<Record<string, unknown>>;
expect(remotes).toHaveLength(1);
// The identity must be decoded to a numeric user id — the debug panel keys
// per-user volume off it.
expect(remotes[0]?.userId).toBe(7);
expect(remotes[0]?.volume).toBe(0.8);
expect(remotes[0]?.effectiveVolume).toBe(0.6);
expect(remotes[0]?.tracks).toEqual([
{ sid: "sid1", source: "microphone", kind: "audio", subscribed: true, enabled: true },
]);
});
it("maps local track publications", () => {
const room = {
name: "r",
state: "connected",
remoteParticipants: new Map(),
localParticipant: {
identity: "user-1",
trackPublications: new Map([
["a", { trackSid: "a", source: "microphone", kind: "audio", isMuted: false }],
["b", { trackSid: "b", source: "screen_share", kind: "video", isMuted: true }],
]),
getTrackPublication: () => undefined,
},
engine: {},
} as unknown as Room;
expect(buildSessionDebugInfo({ ...baseDeps, room }).localTracks).toEqual([
{ sid: "a", source: "microphone", kind: "audio", isMuted: false },
{ sid: "b", source: "screen_share", kind: "video", isMuted: true },
]);
});
it("embeds the ICE state", () => {
const room = {
name: "r",
state: "connected",
remoteParticipants: new Map(),
localParticipant: {
identity: "user-1",
trackPublications: new Map(),
getTrackPublication: () => undefined,
},
engine: { subscriber: { pc: fakePeerConnection({ iceConnectionState: "failed" }) } },
} as unknown as Room;
expect(buildSessionDebugInfo({ ...baseDeps, room }).iceConnectionState).toEqual({
subscriber: { iceConnectionState: "failed", connectionState: "connected" },
});
});
it("carries a null channel id through", () => {
const got = buildSessionDebugInfo({ ...baseDeps, room: null, currentChannelId: null });
expect(got.currentChannelId).toBeNull();
});
});
@@ -0,0 +1,623 @@
/**
* Tests for src/lib/roomEventHandlers.ts (was 57.1% statements / 62.5%
* functions, no test file).
*
* These handlers are the whole reaction surface of a live voice call: audio and
* video attach/detach, speaker highlighting, autoplay unlocking, and the
* disconnect path that decides between "reconnect silently" and "drop the user
* out of voice". The disconnect branch matters most — getting it wrong either
* strands the user in a dead call or tears down a call that was only blipping.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DisconnectReason, Track } from "livekit-client";
import type {
LocalTrackPublication,
Participant,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
Room,
} from "livekit-client";
import { createRoomEventHandlers } from "@lib/roomEventHandlers";
import type { RoomEventDeps } from "@lib/roomEventHandlers";
import { voiceStore } from "@stores/voice.store";
import type { VoiceUser } from "@stores/voice.store";
import type { AudioElements } from "@lib/audioElements";
// ── fakes ──────────────────────────────────────────────────────────────────
function fakeAudioElements(): AudioElements & {
handleTrackSubscribedAudio: ReturnType<typeof vi.fn>;
handleTrackUnsubscribedAudio: ReturnType<typeof vi.fn>;
cleanupAllAudioElements: ReturnType<typeof vi.fn>;
} {
return {
handleTrackSubscribedAudio: vi.fn(),
handleTrackUnsubscribedAudio: vi.fn(),
cleanupAllAudioElements: vi.fn(),
getEffectiveVolume: vi.fn().mockReturnValue(1),
} as unknown as AudioElements & {
handleTrackSubscribedAudio: ReturnType<typeof vi.fn>;
handleTrackUnsubscribedAudio: ReturnType<typeof vi.fn>;
cleanupAllAudioElements: ReturnType<typeof vi.fn>;
};
}
interface Harness {
deps: RoomEventDeps;
handlers: ReturnType<typeof createRoomEventHandlers>;
audioElements: ReturnType<typeof fakeAudioElements>;
room: {
canPlaybackAudio: boolean;
startAudio: ReturnType<typeof vi.fn>;
removeAllListeners: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
};
spies: {
applyMicMuteState: ReturnType<typeof vi.fn>;
attemptAutoReconnect: ReturnType<typeof vi.fn>;
teardownForReconnect: ReturnType<typeof vi.fn>;
leaveVoice: ReturnType<typeof vi.fn>;
setRoom: ReturnType<typeof vi.fn>;
setReconnectAc: ReturnType<typeof vi.fn>;
syncModuleRooms: ReturnType<typeof vi.fn>;
onRemoteVideo: ReturnType<typeof vi.fn>;
onRemoteVideoRemoved: ReturnType<typeof vi.fn>;
onError: ReturnType<typeof vi.fn>;
};
}
function build(over: Partial<RoomEventDeps> = {}): Harness {
const audioElements = fakeAudioElements();
const room = {
canPlaybackAudio: true,
startAudio: vi.fn().mockResolvedValue(undefined),
removeAllListeners: vi.fn(),
disconnect: vi.fn().mockResolvedValue(undefined),
};
const spies = {
applyMicMuteState: vi.fn().mockResolvedValue(undefined),
attemptAutoReconnect: vi.fn().mockResolvedValue(undefined),
teardownForReconnect: vi.fn(),
leaveVoice: vi.fn(),
setRoom: vi.fn(),
setReconnectAc: vi.fn(),
syncModuleRooms: vi.fn(),
onRemoteVideo: vi.fn(),
onRemoteVideoRemoved: vi.fn(),
onError: vi.fn(),
};
const deps: RoomEventDeps = {
getRoom: () => room as unknown as Room,
setRoom: spies.setRoom,
getCurrentChannelId: () => 12,
getAudioElements: () => audioElements,
getOnRemoteVideoCallback: () => spies.onRemoteVideo,
getOnRemoteVideoRemovedCallback: () => spies.onRemoteVideoRemoved,
getOnErrorCallback: () => spies.onError,
isConnecting: () => false,
getLatestToken: () => "tok",
getLastUrl: () => "wss://lk.example",
getLastDirectUrl: () => undefined,
setReconnectAc: spies.setReconnectAc,
syncModuleRooms: spies.syncModuleRooms,
teardownForReconnect: spies.teardownForReconnect,
leaveVoice: spies.leaveVoice,
applyMicMuteState: spies.applyMicMuteState,
attemptAutoReconnect: spies.attemptAutoReconnect,
...over,
};
return { deps, handlers: createRoomEventHandlers(deps), audioElements, room, spies };
}
function audioTrack(): RemoteTrack {
return {
kind: Track.Kind.Audio,
sid: "AT_1",
detach: vi.fn(),
mediaStreamTrack: {} as MediaStreamTrack,
} as unknown as RemoteTrack;
}
function videoTrack(): RemoteTrack & { detach: ReturnType<typeof vi.fn> } {
return {
kind: Track.Kind.Video,
sid: "VT_1",
detach: vi.fn(),
mediaStreamTrack: { id: "mst" } as MediaStreamTrack,
} as unknown as RemoteTrack & { detach: ReturnType<typeof vi.fn> };
}
function pub(source: Track.Source): RemoteTrackPublication {
return { source } as unknown as RemoteTrackPublication;
}
function participant(identity: string): RemoteParticipant {
return { identity } as unknown as RemoteParticipant;
}
beforeEach(() => {
voiceStore.setState((prev) => ({ ...prev, localMuted: false, localDeafened: false }));
vi.stubGlobal(
"MediaStream",
class {
tracks: unknown[];
constructor(tracks: unknown[] = []) {
this.tracks = tracks;
}
},
);
});
// ── handleLocalTrackPublished ──────────────────────────────────────────────
describe("handleLocalTrackPublished", () => {
it("re-applies mute when the local user is muted", () => {
const h = build();
voiceStore.setState((prev) => ({ ...prev, localMuted: true }));
h.handlers.handleLocalTrackPublished({
source: Track.Source.Microphone,
} as LocalTrackPublication);
expect(h.spies.applyMicMuteState).toHaveBeenCalledWith(true);
});
it("re-applies mute when the local user is deafened", () => {
const h = build();
voiceStore.setState((prev) => ({ ...prev, localDeafened: true }));
h.handlers.handleLocalTrackPublished({
source: Track.Source.Microphone,
} as LocalTrackPublication);
expect(h.spies.applyMicMuteState).toHaveBeenCalledWith(true);
});
it("does nothing when neither muted nor deafened", () => {
const h = build();
h.handlers.handleLocalTrackPublished({
source: Track.Source.Microphone,
} as LocalTrackPublication);
expect(h.spies.applyMicMuteState).not.toHaveBeenCalled();
});
it("ignores non-microphone publications", () => {
const h = build();
voiceStore.setState((prev) => ({ ...prev, localMuted: true }));
h.handlers.handleLocalTrackPublished({
source: Track.Source.ScreenShare,
} as LocalTrackPublication);
expect(h.spies.applyMicMuteState).not.toHaveBeenCalled();
});
it("swallows a rejected applyMicMuteState", async () => {
const applyMicMuteState = vi.fn().mockRejectedValue(new Error("no track"));
const h = build({ applyMicMuteState });
voiceStore.setState((prev) => ({ ...prev, localMuted: true }));
expect(() => {
h.handlers.handleLocalTrackPublished({
source: Track.Source.Microphone,
} as LocalTrackPublication);
}).not.toThrow();
await vi.waitFor(() => {
expect(applyMicMuteState).toHaveBeenCalled();
});
});
});
// ── handleTrackSubscribed / Unsubscribed ───────────────────────────────────
describe("handleTrackSubscribed", () => {
it("routes audio tracks to the audio elements manager", () => {
const h = build();
const track = audioTrack();
const publication = pub(Track.Source.Microphone);
const p = participant("user-7:tok");
h.handlers.handleTrackSubscribed(track, publication, p);
expect(h.audioElements.handleTrackSubscribedAudio).toHaveBeenCalledWith(track, publication, p);
expect(h.spies.onRemoteVideo).not.toHaveBeenCalled();
});
it("hands camera video to the remote-video callback", () => {
const h = build();
h.handlers.handleTrackSubscribed(
videoTrack(),
pub(Track.Source.Camera),
participant("user-7:tok"),
);
expect(h.spies.onRemoteVideo).toHaveBeenCalledTimes(1);
const [userId, , isScreenshare] = h.spies.onRemoteVideo.mock.calls[0] as [
number,
MediaStream,
boolean,
];
expect(userId).toBe(7);
expect(isScreenshare).toBe(false);
});
it("flags screenshare video as such", () => {
const h = build();
h.handlers.handleTrackSubscribed(
videoTrack(),
pub(Track.Source.ScreenShare),
participant("user-7:tok"),
);
expect(h.spies.onRemoteVideo.mock.calls[0]?.[2]).toBe(true);
});
it("skips video with an unparseable identity", () => {
const h = build();
h.handlers.handleTrackSubscribed(
videoTrack(),
pub(Track.Source.Camera),
participant("garbage"),
);
expect(h.spies.onRemoteVideo).not.toHaveBeenCalled();
});
it("skips video when no callback is registered", () => {
const h = build({ getOnRemoteVideoCallback: () => null });
expect(() => {
h.handlers.handleTrackSubscribed(
videoTrack(),
pub(Track.Source.Camera),
participant("user-7:tok"),
);
}).not.toThrow();
});
});
describe("handleTrackUnsubscribed", () => {
it("routes audio tracks to the audio elements manager", () => {
const h = build();
const track = audioTrack();
const publication = pub(Track.Source.Microphone);
const p = participant("user-7:tok");
h.handlers.handleTrackUnsubscribed(track, publication, p);
expect(h.audioElements.handleTrackUnsubscribedAudio).toHaveBeenCalledWith(
track,
publication,
p,
);
});
it("detaches the video element and notifies the removal callback", () => {
const h = build();
const track = videoTrack();
h.handlers.handleTrackUnsubscribed(track, pub(Track.Source.Camera), participant("user-9:tok"));
// Without detach the <video> keeps the old MediaStream and the tile freezes
// on the last frame instead of clearing.
expect(track.detach).toHaveBeenCalled();
expect(h.spies.onRemoteVideoRemoved).toHaveBeenCalledWith(9, false);
});
it("flags screenshare removal as such", () => {
const h = build();
h.handlers.handleTrackUnsubscribed(
videoTrack(),
pub(Track.Source.ScreenShare),
participant("user-9:tok"),
);
expect(h.spies.onRemoteVideoRemoved).toHaveBeenCalledWith(9, true);
});
it("still detaches when the identity is unparseable", () => {
const h = build();
const track = videoTrack();
h.handlers.handleTrackUnsubscribed(track, pub(Track.Source.Camera), participant("garbage"));
expect(track.detach).toHaveBeenCalled();
expect(h.spies.onRemoteVideoRemoved).not.toHaveBeenCalled();
});
it("tolerates a missing removal callback", () => {
const h = build({ getOnRemoteVideoRemovedCallback: () => null });
expect(() => {
h.handlers.handleTrackUnsubscribed(
videoTrack(),
pub(Track.Source.Camera),
participant("user-9:tok"),
);
}).not.toThrow();
});
});
// ── handleActiveSpeakersChanged ────────────────────────────────────────────
describe("handleActiveSpeakersChanged", () => {
function speaker(identity: string): Participant {
return { identity } as unknown as Participant;
}
function seedVoiceUsers(channelId: number, userIds: number[]): void {
voiceStore.setState((prev) => {
const users = new Map<number, VoiceUser>(
userIds.map((id) => [
id,
{
userId: id,
username: `u${id}`,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
]),
);
return { ...prev, voiceUsers: new Map([[channelId, users]]) };
});
}
it("marks the reported users as speaking", () => {
seedVoiceUsers(12, [3, 7]);
const h = build();
h.handlers.handleActiveSpeakersChanged([speaker("user-7:tok")]);
const users = voiceStore.getState().voiceUsers.get(12);
expect(users?.get(7)?.speaking).toBe(true);
expect(users?.get(3)?.speaking).toBe(false);
});
it("clears speaking when the list empties", () => {
seedVoiceUsers(12, [7]);
const h = build();
h.handlers.handleActiveSpeakersChanged([speaker("user-7:tok")]);
h.handlers.handleActiveSpeakersChanged([]);
expect(voiceStore.getState().voiceUsers.get(12)?.get(7)?.speaking).toBe(false);
});
it("ignores participants with an unparseable identity", () => {
seedVoiceUsers(12, [7]);
const h = build();
h.handlers.handleActiveSpeakersChanged([speaker("garbage"), speaker("user-7:tok")]);
expect(voiceStore.getState().voiceUsers.get(12)?.get(7)?.speaking).toBe(true);
});
it("does nothing when not in a channel", () => {
seedVoiceUsers(12, [7]);
const h = build({ getCurrentChannelId: () => null });
h.handlers.handleActiveSpeakersChanged([speaker("user-7:tok")]);
expect(voiceStore.getState().voiceUsers.get(12)?.get(7)?.speaking).toBe(false);
});
});
// ── handleAudioPlaybackChanged ─────────────────────────────────────────────
describe("handleAudioPlaybackChanged", () => {
it("does nothing without a room", () => {
const h = build({ getRoom: () => null });
expect(() => {
h.handlers.handleAudioPlaybackChanged();
}).not.toThrow();
});
it("registers a click-to-unlock listener when playback is blocked", async () => {
const h = build();
h.room.canPlaybackAudio = false;
h.handlers.handleAudioPlaybackChanged();
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// The browser blocks autoplay until a user gesture; without this the user
// joins a call and hears nothing at all.
await vi.waitFor(() => {
expect(h.room.startAudio).toHaveBeenCalled();
});
});
it("does not register a listener when playback is allowed", () => {
const h = build();
h.room.canPlaybackAudio = true;
h.handlers.handleAudioPlaybackChanged();
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(h.room.startAudio).not.toHaveBeenCalled();
});
it("replaces a previous unlock listener rather than stacking them", async () => {
const h = build();
h.room.canPlaybackAudio = false;
h.handlers.handleAudioPlaybackChanged();
h.handlers.handleAudioPlaybackChanged();
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => {
expect(h.room.startAudio).toHaveBeenCalledTimes(1);
});
});
it("removeAutoplayUnlock drops the pending listener", () => {
const h = build();
h.room.canPlaybackAudio = false;
h.handlers.handleAudioPlaybackChanged();
h.handlers.removeAutoplayUnlock();
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(h.room.startAudio).not.toHaveBeenCalled();
});
it("removeAutoplayUnlock is safe with nothing registered", () => {
const h = build();
expect(() => {
h.handlers.removeAutoplayUnlock();
h.handlers.removeAutoplayUnlock();
}).not.toThrow();
});
it("a later allowed-playback event clears the pending listener", () => {
const h = build();
h.room.canPlaybackAudio = false;
h.handlers.handleAudioPlaybackChanged();
h.room.canPlaybackAudio = true;
h.handlers.handleAudioPlaybackChanged();
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(h.room.startAudio).not.toHaveBeenCalled();
});
it("the unlock handler tolerates the room disappearing first", () => {
let room: Room | null = { canPlaybackAudio: false } as unknown as Room;
const h = build({ getRoom: () => room });
h.handlers.handleAudioPlaybackChanged();
room = null; // user left voice before clicking
expect(() => {
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}).not.toThrow();
});
});
// ── handleDisconnected ─────────────────────────────────────────────────────
describe("handleDisconnected", () => {
it("defers to the retry loop while still connecting", () => {
const h = build({ isConnecting: () => true });
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
expect(h.spies.attemptAutoReconnect).not.toHaveBeenCalled();
expect(h.spies.leaveVoice).not.toHaveBeenCalled();
});
it("auto-reconnects on an unexpected disconnect", () => {
const h = build();
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
expect(h.spies.teardownForReconnect).toHaveBeenCalled();
expect(h.audioElements.cleanupAllAudioElements).toHaveBeenCalled();
expect(h.spies.setRoom).toHaveBeenCalledWith(null);
expect(h.spies.syncModuleRooms).toHaveBeenCalled();
expect(h.room.removeAllListeners).toHaveBeenCalled();
expect(h.room.disconnect).toHaveBeenCalled();
expect(h.spies.setReconnectAc).toHaveBeenCalledWith(expect.any(AbortController));
expect(h.spies.attemptAutoReconnect).toHaveBeenCalledWith(
"tok",
"wss://lk.example",
12,
undefined,
expect.any(AbortSignal),
);
// A reconnect must not surface an error toast or leave the channel.
expect(h.spies.leaveVoice).not.toHaveBeenCalled();
expect(h.spies.onError).not.toHaveBeenCalled();
});
it("passes the direct URL through to the reconnect attempt", () => {
const h = build({ getLastDirectUrl: () => "wss://direct.example" });
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
expect(h.spies.attemptAutoReconnect).toHaveBeenCalledWith(
"tok",
"wss://lk.example",
12,
"wss://direct.example",
expect.any(AbortSignal),
);
});
it("leaves voice cleanly on a client-initiated disconnect", () => {
const h = build();
h.handlers.handleDisconnected(DisconnectReason.CLIENT_INITIATED);
expect(h.spies.attemptAutoReconnect).not.toHaveBeenCalled();
expect(h.spies.leaveVoice).toHaveBeenCalledWith(false);
// The user asked to leave, so no error is reported.
expect(h.spies.onError).not.toHaveBeenCalled();
});
it.each([
["no token", { getLatestToken: () => null }],
["no channel", { getCurrentChannelId: () => null }],
["no url", { getLastUrl: () => null }],
])("reports an error when it cannot reconnect (%s)", (_label, over) => {
const h = build(over as Partial<RoomEventDeps>);
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
expect(h.spies.attemptAutoReconnect).not.toHaveBeenCalled();
expect(h.spies.leaveVoice).toHaveBeenCalledWith(false);
expect(h.spies.onError).toHaveBeenCalledWith("Voice connection lost — disconnected");
});
it("treats an undefined reason as unexpected", () => {
const h = build();
h.handlers.handleDisconnected(undefined);
expect(h.spies.attemptAutoReconnect).toHaveBeenCalled();
});
it("tolerates the room already being gone", () => {
const h = build({ getRoom: () => null });
expect(() => {
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
}).not.toThrow();
expect(h.spies.attemptAutoReconnect).toHaveBeenCalled();
});
it("swallows a failing disconnect on the stale room", async () => {
const h = build();
h.room.disconnect.mockRejectedValue(new Error("already closed"));
expect(() => {
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
}).not.toThrow();
await vi.waitFor(() => {
expect(h.room.disconnect).toHaveBeenCalled();
});
});
it("tolerates a missing error callback", () => {
const h = build({ getLatestToken: () => null, getOnErrorCallback: () => null });
expect(() => {
h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN);
}).not.toThrow();
});
});
@@ -0,0 +1,653 @@
/**
* Tests for the track-publishing half of src/lib/screenShare.ts.
*
* The module sat at 61.1% statements: screen-share-fps.test.ts covers the
* preset/bitrate helpers, but enableCamera, disableCamera, enableScreenshare,
* disableScreenshare, the stopManual* helpers and the stream getters had no
* coverage at all.
*
* The failure paths are the point. BUG-100 (stop the created track when publish
* fails, or the camera light stays on with nothing published) and BUG-101
* (honour the OS "Stop sharing" button) are both regressions that a happy-path
* test would sail straight past.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Track } from "livekit-client";
import type { LocalTrack, LocalVideoTrack, Room } from "livekit-client";
import type { WsClient } from "@lib/ws";
const createLocalVideoTrack = vi.fn();
const createLocalScreenTracks = vi.fn();
const loadPref = vi.fn();
vi.mock("livekit-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("livekit-client")>();
return {
...actual,
createLocalVideoTrack: (...args: unknown[]) => createLocalVideoTrack(...args) as unknown,
createLocalScreenTracks: (...args: unknown[]) => createLocalScreenTracks(...args) as unknown,
};
});
vi.mock("@components/settings/helpers", () => ({
loadPref: (...args: unknown[]) => loadPref(...args) as unknown,
savePref: vi.fn(),
}));
const {
disableCamera,
disableScreenshare,
enableCamera,
enableScreenshare,
getLocalCameraStream,
getLocalScreenshareStream,
getRemoteVideoStream,
stopManualCameraTrack,
stopManualScreenTracks,
} = await import("@lib/screenShare");
type VideoTrackDeps = Parameters<typeof enableCamera>[1];
const { voiceStore } = await import("@stores/voice.store");
// ── fakes ──────────────────────────────────────────────────────────────────
function fakeMediaStreamTrack(): MediaStreamTrack {
const listeners = new Map<string, EventListener>();
return {
addEventListener: (type: string, cb: EventListener) => listeners.set(type, cb),
dispatch: (type: string) => listeners.get(type)?.(new Event(type)),
} as unknown as MediaStreamTrack;
}
function fakeVideoTrack(): LocalVideoTrack & { stop: ReturnType<typeof vi.fn> } {
return {
kind: Track.Kind.Video,
mediaStreamTrack: fakeMediaStreamTrack(),
stop: vi.fn(),
} as unknown as LocalVideoTrack & { stop: ReturnType<typeof vi.fn> };
}
function fakeAudioTrack(): LocalTrack & { stop: ReturnType<typeof vi.fn> } {
return {
kind: Track.Kind.Audio,
mediaStreamTrack: fakeMediaStreamTrack(),
stop: vi.fn(),
} as unknown as LocalTrack & { stop: ReturnType<typeof vi.fn> };
}
interface RoomRig {
room: Room;
publishTrack: ReturnType<typeof vi.fn>;
unpublishTrack: ReturnType<typeof vi.fn>;
setCameraEnabled: ReturnType<typeof vi.fn>;
setScreenShareEnabled: ReturnType<typeof vi.fn>;
}
function fakeRoom(): RoomRig {
const publishTrack = vi.fn().mockResolvedValue(undefined);
const unpublishTrack = vi.fn().mockResolvedValue(undefined);
const setCameraEnabled = vi.fn().mockResolvedValue(undefined);
const setScreenShareEnabled = vi.fn().mockResolvedValue(undefined);
const room = {
localParticipant: {
publishTrack,
unpublishTrack,
setCameraEnabled,
setScreenShareEnabled,
getTrackPublication: () => undefined,
},
remoteParticipants: new Map(),
} as unknown as Room;
return { room, publishTrack, unpublishTrack, setCameraEnabled, setScreenShareEnabled };
}
/**
* Builds a VideoTrackDeps with spies attached. `wsSend` is surfaced directly
* rather than reached through `getWs()`, which narrows to `never` once a test
* passes `hasWs: false`.
*/
function fakeDeps(
room: Room | null,
hasWs = true,
): VideoTrackDeps & {
wsSend: ReturnType<typeof vi.fn>;
onError: ReturnType<typeof vi.fn>;
reapplyAudioPipeline: ReturnType<typeof vi.fn>;
} {
const wsSend = vi.fn();
const ws = hasWs ? ({ send: wsSend } as unknown as WsClient) : null;
return {
getRoom: () => room,
getWs: () => ws,
onError: vi.fn(),
reapplyAudioPipeline: vi.fn(),
wsSend,
};
}
beforeEach(() => {
createLocalVideoTrack.mockReset();
createLocalScreenTracks.mockReset();
loadPref.mockReset().mockReturnValue("");
voiceStore.setState((prev) => ({ ...prev, localCamera: false, localScreenshare: false }));
vi.stubGlobal(
"MediaStream",
class {
tracks: unknown[];
constructor(tracks: unknown[] = []) {
this.tracks = tracks;
}
},
);
});
// ── stopManualCameraTrack ──────────────────────────────────────────────────
describe("stopManualCameraTrack", () => {
it("unpublishes and stops the track", () => {
const rig = fakeRoom();
const track = fakeVideoTrack();
const state = { manualCameraTrack: track };
stopManualCameraTrack(state, rig.room);
expect(rig.unpublishTrack).toHaveBeenCalledWith(track.mediaStreamTrack);
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
});
it("is a no-op with no track", () => {
const rig = fakeRoom();
stopManualCameraTrack({ manualCameraTrack: null }, rig.room);
expect(rig.unpublishTrack).not.toHaveBeenCalled();
});
it("is a no-op with no room", () => {
const track = fakeVideoTrack();
const state = { manualCameraTrack: track };
stopManualCameraTrack(state, null);
expect(track.stop).not.toHaveBeenCalled();
expect(state.manualCameraTrack).toBe(track);
});
it("still stops the track when unpublish throws", () => {
const rig = fakeRoom();
rig.unpublishTrack.mockImplementation(() => {
throw new Error("already unpublished");
});
const track = fakeVideoTrack();
stopManualCameraTrack({ manualCameraTrack: track }, rig.room);
// Stopping is what releases the hardware; it must not be skipped because
// the unpublish leg failed.
expect(track.stop).toHaveBeenCalled();
});
});
// ── enableCamera ───────────────────────────────────────────────────────────
describe("enableCamera", () => {
it("publishes the camera track and announces it over the websocket", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
await enableCamera(state, deps);
expect(rig.publishTrack).toHaveBeenCalledWith(
track,
expect.objectContaining({ source: Track.Source.Camera }),
);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: true },
});
expect(deps.reapplyAudioPipeline).toHaveBeenCalled();
expect(state.manualCameraTrack).toBe(track);
expect(voiceStore.getState().localCamera).toBe(true);
});
it("uses the saved video input device when one is set", async () => {
const rig = fakeRoom();
loadPref.mockReturnValue("cam-2");
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: null }, fakeDeps(rig.room));
expect(createLocalVideoTrack).toHaveBeenCalledWith(
expect.objectContaining({ deviceId: "cam-2" }),
);
});
it("omits deviceId when no device is saved", async () => {
const rig = fakeRoom();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: null }, fakeDeps(rig.room));
expect(createLocalVideoTrack.mock.calls[0]?.[0]).not.toHaveProperty("deviceId");
});
it("refuses when there is no voice session", async () => {
const deps = fakeDeps(null);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
expect(createLocalVideoTrack).not.toHaveBeenCalled();
expect(voiceStore.getState().localCamera).toBe(false);
});
it("refuses when the websocket is gone", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room, false);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
});
it("releases the created track when publishing fails (BUG-100)", async () => {
const rig = fakeRoom();
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
rig.publishTrack.mockRejectedValue(new Error("publish failed"));
const deps = fakeDeps(rig.room);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
await enableCamera(state, deps);
// Without this the camera indicator light stays on with nothing published.
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
expect(voiceStore.getState().localCamera).toBe(false);
expect(deps.onError).toHaveBeenCalledWith("Failed to start camera");
});
it.each([
["NotAllowedError", "Camera permission denied"],
["NotFoundError", "No camera found"],
["OverconstrainedError", "Failed to start camera"],
])("maps a %s to a specific message", async (name, message) => {
const rig = fakeRoom();
createLocalVideoTrack.mockRejectedValue(new DOMException("nope", name));
const deps = fakeDeps(rig.room);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith(message);
});
it("stops a previously published track before publishing a new one", async () => {
const rig = fakeRoom();
const old = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: old }, fakeDeps(rig.room));
expect(old.stop).toHaveBeenCalled();
});
});
// ── disableCamera ──────────────────────────────────────────────────────────
describe("disableCamera", () => {
it("stops the track, disables the camera and announces it", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
const state = { manualCameraTrack: track as LocalVideoTrack | null };
await disableCamera(state, deps);
expect(track.stop).toHaveBeenCalled();
expect(rig.setCameraEnabled).toHaveBeenCalledWith(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
expect(voiceStore.getState().localCamera).toBe(false);
});
it("still clears local state when the room call throws", async () => {
const rig = fakeRoom();
rig.setCameraEnabled.mockRejectedValue(new Error("disconnected"));
const deps = fakeDeps(rig.room);
voiceStore.setState((prev) => ({ ...prev, localCamera: true }));
await disableCamera({ manualCameraTrack: null }, deps);
// The finally block matters: a failed teardown must not leave the UI
// showing a camera that is not publishing.
expect(voiceStore.getState().localCamera).toBe(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
});
it("works with no room", async () => {
const deps = fakeDeps(null);
await disableCamera({ manualCameraTrack: null }, deps);
expect(voiceStore.getState().localCamera).toBe(false);
});
it("skips the websocket notice when the socket is gone", async () => {
const rig = fakeRoom();
await expect(
disableCamera({ manualCameraTrack: null }, fakeDeps(rig.room, false)),
).resolves.toBeUndefined();
});
});
// ── stopManualScreenTracks ─────────────────────────────────────────────────
describe("stopManualScreenTracks", () => {
it("unpublishes and stops every track", () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
const state = { manualScreenTracks: [video, audio] as LocalTrack[] };
stopManualScreenTracks(state, rig.room);
expect(rig.unpublishTrack).toHaveBeenCalledTimes(2);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
});
it("is a no-op with no tracks", () => {
const rig = fakeRoom();
stopManualScreenTracks({ manualScreenTracks: [] }, rig.room);
expect(rig.unpublishTrack).not.toHaveBeenCalled();
});
it("is a no-op with no room", () => {
const video = fakeVideoTrack();
const state = { manualScreenTracks: [video] as LocalTrack[] };
stopManualScreenTracks(state, null);
expect(video.stop).not.toHaveBeenCalled();
});
it("keeps stopping the remaining tracks when one unpublish throws", () => {
const rig = fakeRoom();
rig.unpublishTrack.mockImplementationOnce(() => {
throw new Error("gone");
});
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
stopManualScreenTracks({ manualScreenTracks: [video, audio] as LocalTrack[] }, rig.room);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
});
});
// ── enableScreenshare ──────────────────────────────────────────────────────
describe("enableScreenshare", () => {
it("publishes video and audio tracks with the right sources", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(rig.publishTrack).toHaveBeenCalledWith(
video,
expect.objectContaining({ source: Track.Source.ScreenShare }),
);
expect(rig.publishTrack).toHaveBeenCalledWith(
audio,
expect.objectContaining({ source: Track.Source.ScreenShareAudio }),
);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: true },
});
expect(voiceStore.getState().localScreenshare).toBe(true);
});
it("sets a video encoding on the video track only", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
await enableScreenshare({ manualScreenTracks: [] }, fakeDeps(rig.room));
const videoOpts = rig.publishTrack.mock.calls.find((c) => c[0] === video)?.[1] as Record<
string,
unknown
>;
const audioOpts = rig.publishTrack.mock.calls.find((c) => c[0] === audio)?.[1] as Record<
string,
unknown
>;
expect(videoOpts).toHaveProperty("videoEncoding");
expect(audioOpts).not.toHaveProperty("videoEncoding");
});
it("tears down when the OS stop-sharing button ends the track (BUG-101)", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
createLocalScreenTracks.mockResolvedValue([video]);
const deps = fakeDeps(rig.room);
const state = { manualScreenTracks: [] as LocalTrack[] };
await enableScreenshare(state, deps);
deps.wsSend.mockClear();
// Simulate the browser/OS ending the capture without going through the app.
(video.mediaStreamTrack as unknown as { dispatch: (t: string) => void }).dispatch("ended");
await vi.waitFor(() => {
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
});
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("refuses when there is no voice session", async () => {
const deps = fakeDeps(null);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
expect(createLocalScreenTracks).not.toHaveBeenCalled();
});
it("releases created tracks when publishing fails (BUG-100)", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
createLocalScreenTracks.mockResolvedValue([video]);
rig.publishTrack.mockRejectedValue(new Error("publish failed"));
const deps = fakeDeps(rig.room);
const state = { manualScreenTracks: [] as LocalTrack[] };
await enableScreenshare(state, deps);
// Otherwise the OS keeps showing "screen is being shared" forever.
expect(video.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
expect(deps.onError).toHaveBeenCalledWith("Failed to start screen sharing");
});
it("reports a denied picker separately", async () => {
const rig = fakeRoom();
createLocalScreenTracks.mockRejectedValue(new DOMException("no", "NotAllowedError"));
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).toHaveBeenCalledWith("Screen sharing permission denied");
});
it("tolerates a capture with no video track", async () => {
const rig = fakeRoom();
createLocalScreenTracks.mockResolvedValue([fakeAudioTrack()]);
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).not.toHaveBeenCalled();
});
});
// ── disableScreenshare ─────────────────────────────────────────────────────
describe("disableScreenshare", () => {
it("stops tracks, disables sharing and announces it", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const video = fakeVideoTrack();
await disableScreenshare({ manualScreenTracks: [video] as LocalTrack[] }, deps);
expect(video.stop).toHaveBeenCalled();
expect(rig.setScreenShareEnabled).toHaveBeenCalledWith(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("still clears local state when the room call throws", async () => {
const rig = fakeRoom();
rig.setScreenShareEnabled.mockRejectedValue(new Error("disconnected"));
voiceStore.setState((prev) => ({ ...prev, localScreenshare: true }));
await disableScreenshare({ manualScreenTracks: [] }, fakeDeps(rig.room));
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("works with no room", async () => {
await disableScreenshare({ manualScreenTracks: [] }, fakeDeps(null));
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
// ── stream getters ─────────────────────────────────────────────────────────
describe("stream getters", () => {
function roomWithLocalPub(source: Track.Source, track: unknown): Room {
return {
localParticipant: {
getTrackPublication: (s: Track.Source) => (s === source ? { track } : undefined),
},
remoteParticipants: new Map(),
} as unknown as Room;
}
it("getLocalCameraStream returns null without a room", () => {
expect(getLocalCameraStream(null)).toBeNull();
});
it("getLocalCameraStream returns null when nothing is published", () => {
expect(getLocalCameraStream(fakeRoom().room)).toBeNull();
});
it("getLocalCameraStream wraps the published camera track", () => {
const room = roomWithLocalPub(Track.Source.Camera, { mediaStreamTrack: {} });
expect(getLocalCameraStream(room)).not.toBeNull();
});
it("getLocalScreenshareStream returns null without a room", () => {
expect(getLocalScreenshareStream(null)).toBeNull();
});
it("getLocalScreenshareStream wraps the published screenshare track", () => {
const room = roomWithLocalPub(Track.Source.ScreenShare, { mediaStreamTrack: {} });
expect(getLocalScreenshareStream(room)).not.toBeNull();
});
it("getLocalScreenshareStream returns null when nothing is published", () => {
expect(getLocalScreenshareStream(fakeRoom().room)).toBeNull();
});
});
describe("getRemoteVideoStream", () => {
function roomWithRemote(identity: string, source: Track.Source, track: unknown): Room {
return {
localParticipant: { getTrackPublication: () => undefined },
remoteParticipants: new Map([
[
identity,
{
identity,
getTrackPublication: (s: Track.Source) => (s === source ? { track } : undefined),
},
],
]),
} as unknown as Room;
}
it("returns null without a room", () => {
expect(getRemoteVideoStream(null, 7, "camera")).toBeNull();
});
it("matches an identity carrying a join-token suffix", () => {
// getParticipantByIdentity would miss this, which is why the module scans.
const room = roomWithRemote("user-42:abc123", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).not.toBeNull();
});
it("matches a bare identity", () => {
const room = roomWithRemote("user-42", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).not.toBeNull();
});
it("selects the screenshare source when asked", () => {
const room = roomWithRemote("user-42:tok", Track.Source.ScreenShare, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "screenshare")).not.toBeNull();
expect(getRemoteVideoStream(room, 42, "camera")).toBeNull();
});
it("returns null for a user who is not in the room", () => {
const room = roomWithRemote("user-42:tok", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 99, "camera")).toBeNull();
});
it("does not confuse user 4 with user 42", () => {
const room = roomWithRemote("user-42:tok", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 4, "camera")).toBeNull();
});
it("ignores participants with an unparseable identity", () => {
const room = roomWithRemote("anonymous", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).toBeNull();
});
});
@@ -0,0 +1,158 @@
/**
* Tests for src/lib/updater.ts.
*
* Excluded from coverage in vitest.config.ts and only ever `vi.mock`ed by
* update-notifier.test.ts, so none of it had run under test. It drives the
* self-update path — a failed check must degrade to "no update" rather than
* surface an error, and the progress listener must be detached even when the
* install throws, or a failed update leaves a dangling Tauri event listener.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const invoke = vi.fn();
const relaunch = vi.fn();
const listen = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args) as unknown,
}));
vi.mock("@tauri-apps/plugin-process", () => ({
relaunch: (...args: unknown[]) => relaunch(...args) as unknown,
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: (...args: unknown[]) => listen(...args) as unknown,
}));
const { checkForUpdate, downloadAndInstallUpdate } = await import("@lib/updater");
const unlisten = vi.fn();
beforeEach(() => {
invoke.mockReset().mockResolvedValue(undefined);
relaunch.mockReset().mockResolvedValue(undefined);
unlisten.mockReset();
listen.mockReset().mockResolvedValue(unlisten);
});
// ── checkForUpdate ─────────────────────────────────────────────────────────
describe("checkForUpdate", () => {
it("returns the backend result when an update is available", async () => {
invoke.mockResolvedValue({ available: true, version: "1.2.3", body: "notes" });
await expect(checkForUpdate("https://s.example")).resolves.toEqual({
available: true,
version: "1.2.3",
body: "notes",
});
expect(invoke).toHaveBeenCalledWith("check_client_update", {
serverUrl: "https://s.example",
});
});
it("returns the backend result when no update is available", async () => {
invoke.mockResolvedValue({ available: false, version: null, body: null });
await expect(checkForUpdate("https://s.example")).resolves.toEqual({
available: false,
version: null,
body: null,
});
});
it("degrades to 'no update' when the check fails", async () => {
invoke.mockRejectedValue(new Error("server unreachable"));
// An unreachable or older server must not break the client — it just means
// there is no update to offer.
await expect(checkForUpdate("https://s.example")).resolves.toEqual({
available: false,
version: null,
body: null,
});
});
});
// ── downloadAndInstallUpdate ───────────────────────────────────────────────
describe("downloadAndInstallUpdate", () => {
it("installs and relaunches", async () => {
await downloadAndInstallUpdate("https://s.example");
expect(invoke).toHaveBeenCalledWith("download_and_install_update", {
serverUrl: "https://s.example",
});
expect(relaunch).toHaveBeenCalled();
});
it("does not subscribe to progress when no callback is given", async () => {
await downloadAndInstallUpdate("https://s.example");
expect(listen).not.toHaveBeenCalled();
});
it("subscribes to update-progress when a callback is given", async () => {
await downloadAndInstallUpdate("https://s.example", vi.fn());
expect(listen).toHaveBeenCalledWith("update-progress", expect.any(Function));
});
it("forwards progress events to the callback", async () => {
const onProgress = vi.fn();
await downloadAndInstallUpdate("https://s.example", onProgress);
const handler = listen.mock.calls[0]?.[1] as (e: {
payload: { received: number; total?: number | null };
}) => void;
handler({ payload: { received: 512, total: 2048 } });
expect(onProgress).toHaveBeenCalledWith({ received: 512, total: 2048 });
});
it("normalises a missing total to null", async () => {
const onProgress = vi.fn();
await downloadAndInstallUpdate("https://s.example", onProgress);
const handler = listen.mock.calls[0]?.[1] as (e: {
payload: { received: number; total?: number | null };
}) => void;
// A server that sends no Content-Length yields an undefined total; the UI
// needs a null it can branch on to show an indeterminate bar.
handler({ payload: { received: 512 } });
expect(onProgress).toHaveBeenCalledWith({ received: 512, total: null });
});
it("detaches the progress listener after a successful install", async () => {
await downloadAndInstallUpdate("https://s.example", vi.fn());
expect(unlisten).toHaveBeenCalled();
});
it("detaches the progress listener when the install fails", async () => {
invoke.mockRejectedValue(new Error("download failed"));
await expect(downloadAndInstallUpdate("https://s.example", vi.fn())).rejects.toThrow(
"download failed",
);
// The finally block is what stops a failed update from leaking a listener
// that keeps firing into a dead progress bar on the next attempt.
expect(unlisten).toHaveBeenCalled();
});
it("does not relaunch when the install fails", async () => {
invoke.mockRejectedValue(new Error("download failed"));
await expect(downloadAndInstallUpdate("https://s.example")).rejects.toThrow("download failed");
expect(relaunch).not.toHaveBeenCalled();
});
it("propagates a relaunch failure", async () => {
relaunch.mockRejectedValue(new Error("relaunch blocked"));
await expect(downloadAndInstallUpdate("https://s.example")).rejects.toThrow("relaunch blocked");
});
});
@@ -0,0 +1,241 @@
/**
* Tests for src/components/channel-sidebar/volume-menu.ts (was 77.7% statements
* / 50% functions, no test file — every event handler was unexercised).
*
* The menu is transient DOM appended to document.body with two AbortControllers
* governing its teardown, so the interesting failures are leaks: a menu that
* outlives its sidebar, or a dismiss listener that survives its menu.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const setUserVolume = vi.fn();
const getUserVolume = vi.fn();
vi.mock("@lib/livekitSession", () => ({
setUserVolume: (...args: unknown[]) => setUserVolume(...args) as unknown,
getUserVolume: (...args: unknown[]) => getUserVolume(...args) as unknown,
}));
const { showUserVolumeMenu } = await import("@components/channel-sidebar/volume-menu");
function menuEl(): HTMLElement | null {
return document.querySelector(".user-vol-menu");
}
function sliderEl(): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>(".user-vol-menu input[type=range]");
}
function itemTexts(): string[] {
return [...document.querySelectorAll(".user-vol-menu .context-menu-item")].map(
(el) => el.textContent ?? "",
);
}
beforeEach(() => {
vi.useFakeTimers();
setUserVolume.mockReset();
getUserVolume.mockReset().mockReturnValue(100);
document.body.innerHTML = "";
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
// ── rendering ──────────────────────────────────────────────────────────────
describe("showUserVolumeMenu rendering", () => {
it("renders the username, the current volume and a 0-200 slider", () => {
getUserVolume.mockReturnValue(140);
showUserVolumeMenu(7, "alice", 10, 20, new AbortController().signal);
expect(menuEl()).not.toBeNull();
expect(itemTexts()).toContain("alice");
expect(itemTexts()).toContain("User Volume: 140%");
const slider = sliderEl();
expect(slider?.value).toBe("140");
expect(slider?.min).toBe("0");
expect(slider?.max).toBe("200");
});
it("positions the menu at the supplied coordinates", () => {
showUserVolumeMenu(7, "alice", 123, 456, new AbortController().signal);
expect(menuEl()?.style.left).toBe("123px");
expect(menuEl()?.style.top).toBe("456px");
});
it("reads the current volume for the requested user", () => {
showUserVolumeMenu(42, "bob", 0, 0, new AbortController().signal);
expect(getUserVolume).toHaveBeenCalledWith(42);
});
it("renders a volume of 0 rather than treating it as absent", () => {
getUserVolume.mockReturnValue(0);
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
expect(itemTexts()).toContain("User Volume: 0%");
expect(sliderEl()?.value).toBe("0");
});
});
// ── slider ─────────────────────────────────────────────────────────────────
describe("volume slider", () => {
it("applies the new volume and updates both labels", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
const slider = sliderEl();
if (slider === null) throw new Error("no slider rendered");
slider.value = "55";
slider.dispatchEvent(new Event("input", { bubbles: true }));
expect(setUserVolume).toHaveBeenCalledWith(7, 55);
expect(itemTexts()).toContain("User Volume: 55%");
expect(document.querySelector(".slider-val")?.textContent).toBe("55%");
});
it("supports boosting above 100%", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
const slider = sliderEl();
if (slider === null) throw new Error("no slider rendered");
slider.value = "200";
slider.dispatchEvent(new Event("input", { bubbles: true }));
expect(setUserVolume).toHaveBeenCalledWith(7, 200);
});
it("supports muting to 0%", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
const slider = sliderEl();
if (slider === null) throw new Error("no slider rendered");
slider.value = "0";
slider.dispatchEvent(new Event("input", { bubbles: true }));
expect(setUserVolume).toHaveBeenCalledWith(7, 0);
expect(itemTexts()).toContain("User Volume: 0%");
});
});
// ── reset ──────────────────────────────────────────────────────────────────
describe("reset button", () => {
it("restores 100% in the store, the slider and both labels", () => {
getUserVolume.mockReturnValue(30);
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
const reset = [
...document.querySelectorAll<HTMLElement>(".user-vol-menu .context-menu-item"),
].find((el) => el.textContent === "Reset Volume");
reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(setUserVolume).toHaveBeenCalledWith(7, 100);
expect(sliderEl()?.value).toBe("100");
expect(itemTexts()).toContain("User Volume: 100%");
expect(document.querySelector(".slider-val")?.textContent).toBe("100%");
});
});
// ── dismissal and teardown ─────────────────────────────────────────────────
describe("dismissal", () => {
it("closes on a mousedown outside the menu", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
vi.runAllTimers(); // the outside-click listener is attached on a macrotask
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(menuEl()).toBeNull();
});
it("stays open on a mousedown inside the menu", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
vi.runAllTimers();
sliderEl()?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(menuEl()).not.toBeNull();
});
it("ignores clicks landing before the listener is attached", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
// The setTimeout(0) exists so the right-click that opened the menu does not
// immediately close it again.
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(menuEl()).not.toBeNull();
});
it("removes the menu when the parent component aborts", () => {
const ac = new AbortController();
showUserVolumeMenu(7, "alice", 0, 0, ac.signal);
vi.runAllTimers();
ac.abort();
expect(menuEl()).toBeNull();
});
it("does not re-attach the dismiss listener when aborted before the timer fires", () => {
const ac = new AbortController();
showUserVolumeMenu(7, "alice", 0, 0, ac.signal);
ac.abort();
// The scheduled callback checks the dismiss signal first, so no listener is
// registered against an already-removed menu.
expect(() => {
vi.runAllTimers();
}).not.toThrow();
expect(menuEl()).toBeNull();
});
});
describe("re-opening", () => {
it("replaces any menu already on screen", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
vi.runAllTimers();
showUserVolumeMenu(9, "bob", 50, 60, new AbortController().signal);
expect(document.querySelectorAll(".user-vol-menu")).toHaveLength(1);
expect(itemTexts()).toContain("bob");
expect(itemTexts()).not.toContain("alice");
});
it("the replaced menu's dismiss listener no longer closes the new menu", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
vi.runAllTimers(); // first menu's outside-click listener is live
showUserVolumeMenu(9, "bob", 0, 0, new AbortController().signal);
// Deliberately do NOT run timers: only the stale listener from the first
// menu is attached. It was aborted on replace, so this click must not close
// the freshly opened menu.
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(menuEl()).not.toBeNull();
expect(itemTexts()).toContain("bob");
});
it("the new menu operates on the new user", () => {
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
showUserVolumeMenu(9, "bob", 0, 0, new AbortController().signal);
const slider = sliderEl();
if (slider === null) throw new Error("no slider rendered");
slider.value = "20";
slider.dispatchEvent(new Event("input", { bubbles: true }));
expect(setUserVolume).toHaveBeenCalledWith(9, 20);
expect(setUserVolume).not.toHaveBeenCalledWith(7, 20);
});
});
+12 -6
View File
@@ -19,15 +19,21 @@ export default defineConfig({
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
// Keep this list minimal and justified. An unexplained entry hides a
// real gap: window-state.ts, credentials.ts, updater.ts and
// UpdateNotifier.ts each sat here while having (or gaining) tests, so
// their coverage never showed up in any report.
exclude: [
"src/main.ts",
"src/**/*.d.ts",
"src/lib/window-state.ts",
"src/lib/credentials.ts",
"src/lib/noise-suppression.ts",
"src/lib/updater.ts",
// App bootstrap: wires the DOM, router and stores together at startup.
// Has no seam to test below the e2e level; covered by tests/e2e.
"src/main.ts",
// Top-level page orchestrator, likewise covered at the e2e level.
// Tracked for unit coverage — remove this entry once it has tests.
"src/pages/MainPage.ts",
"src/components/UpdateNotifier.ts",
// RNNoise AudioWorklet host: needs a real AudioContext/WASM runtime
// that jsdom cannot provide. Exercised by tests/browser and e2e.
"src/lib/noise-suppression.ts",
],
thresholds: {
statements: 70,
+32 -1
View File
@@ -1,5 +1,9 @@
# OwnCord Server — developer convenience targets
#
# test Run the test suite the way CI does (race + timeout).
# test-deadlock Run the deadlock-detection pass CI also runs.
# cover Per-package coverage (what CI uploads) + a function summary.
# cover-all Cross-package coverage — the honest number. See below.
# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen).
# sqlc-verify Fail if the committed dbgen output is stale (used by CI).
# sqlc-install Install the pinned sqlc version into $GOBIN.
@@ -10,7 +14,34 @@
SQLC_VERSION := $(shell cat sqlc.version)
.PHONY: sqlc-install sqlc-generate sqlc-verify protocol-generate protocol-verify otel-up otel-down
.PHONY: test test-deadlock cover cover-all sqlc-install sqlc-generate sqlc-verify \
protocol-generate protocol-verify otel-up otel-down
test:
go test -race -timeout 20m ./...
test-deadlock:
go test -tags deadlock -count=1 ./...
# Matches the CI invocation. Note that `go test ./... -coverprofile` instruments
# each package only for itself, so a package whose code is mostly exercised
# through another package's tests reports far lower than its real coverage
# (`service` reads ~37% here versus ~85% cross-package). Use cover-all for the
# number to reason about; this target exists to reproduce the CI artifact.
cover:
go test ./... -coverprofile=coverage.out -cover
@go tool cover -func=coverage.out | tail -1
# Cross-package coverage: every package is instrumented for every test binary,
# so code reached indirectly is counted. Prints the functions no test reaches at
# all — the list to work from when closing gaps.
cover-all:
go test -count=1 -coverpkg=./... -coverprofile=coverage-all.out ./...
@echo
@echo "── functions with no coverage ──────────────────────────────────────"
@go tool cover -func=coverage-all.out | awk '$$NF=="0.0%"' | sed 's|github.com/owncord/server/||'
@echo
@go tool cover -func=coverage-all.out | tail -1
sqlc-install:
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION)
+33 -7
View File
@@ -406,23 +406,47 @@ func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {}
func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {}
func (m *mockHubWB) ClientCount() int { return 0 }
// isolateSpawnedTestBinary makes it safe for a test to re-exec the test binary
// itself.
//
// Two things leak from parent to child otherwise, and both corrupt the coverage
// report for the whole package:
//
// 1. GOCOVERDIR is inherited, so the coverage-instrumented child writes its own
// (near-empty) counter set into the parent's coverage directory. The result
// is that `go test ./... -coverprofile` reported `admin coverage: 0.3% of
// statements` instead of ~71%, and CI's uploaded coverage.out was wrong for
// this package.
// 2. SpawnDetached wires the child's stdout to the parent's, so anything the
// child's testing framework prints lands in the stream `go test` parses.
// "-test.run=^$" makes it print "testing: warning: no tests to run", which
// `go test` reported as "[no tests to run]" for the parent run.
//
// Point the child's counters at a throwaway directory (t.Setenv restores the
// old value automatically), and use "-test.list" rather than "-test.run" so the
// child exits without printing anything.
func isolateSpawnedTestBinary(t *testing.T) []string {
t.Helper()
t.Setenv("GOCOVERDIR", t.TempDir())
return []string{"-test.list=^$"}
}
// TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a
// real executable (the Go test binary itself) with a flag that causes immediate
// exit. The test only checks that cmd.Start() returns without error; it does
// not wait for the child process to finish.
func TestSpawnDetached_ValidExecutable(t *testing.T) {
// Use the current test binary as the spawned executable so we don't depend
// on any external tool being available.
//
// os.Args[0] is the test binary itself. We pass "-test.run=^$" so the child
// immediately exits with 0 (no tests match). This avoids infinite recursion
// and any visible side effects.
// on any external tool being available. See isolateSpawnedTestBinary for
// why the child needs its own GOCOVERDIR and a silent exit flag.
args := isolateSpawnedTestBinary(t)
selfExe, err := filepath.Abs(os.Args[0])
if err != nil {
t.Fatalf("abs path of test binary: %v", err)
}
err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"})
err = updater.SpawnDetached(selfExe, args)
if err != nil {
t.Errorf("spawnDetached returned error: %v", err)
}
@@ -445,13 +469,15 @@ func TestSpawnDetached_SetsWindowsFlags(t *testing.T) {
t.Skip("SysProcAttr Windows-specific flag test only runs on Windows")
}
args := isolateSpawnedTestBinary(t)
selfExe, err := filepath.Abs(os.Args[0])
if err != nil {
t.Fatalf("abs path: %v", err)
}
// Just verify it doesn't panic when setting the Windows creation flag.
err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"})
err = updater.SpawnDetached(selfExe, args)
if err != nil {
t.Errorf("spawnDetached on Windows returned error: %v", err)
}
+301
View File
@@ -0,0 +1,301 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
)
// The multiHandler tees every server log record into the admin panel's live
// log stream. Eleven of its functions had no coverage — including Subscribe,
// which is what a connected admin's SSE session hangs off. A silent break here
// makes the log viewer look like a quiet server.
// newTeeLogger wires a logger through NewMultiHandler and returns the logger,
// the ring buffer it feeds, and an accessor for the stdout side.
func newTeeLogger(t *testing.T, minLevel slog.Leveler) (*slog.Logger, *RingBuffer, func() string) {
t.Helper()
var stdout bytes.Buffer
buf := NewRingBuffer(16)
h := NewMultiHandler(
slog.NewTextHandler(&stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
buf, minLevel,
)
return slog.New(h), buf, stdout.String
}
func TestNewMultiHandler_TeesToBothSinks(t *testing.T) {
logger, buf, stdout := newTeeLogger(t, slog.LevelDebug)
logger.Info("hello admin")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
if entries[0].Message != "hello admin" {
t.Errorf("Message = %q, want %q", entries[0].Message, "hello admin")
}
if entries[0].Level != "INFO" {
t.Errorf("Level = %q, want INFO", entries[0].Level)
}
if entries[0].Timestamp == "" {
t.Error("Timestamp is empty")
}
if !strings.Contains(stdout(), "hello admin") {
t.Errorf("record did not reach stdout; got %q", stdout())
}
}
func TestMultiHandler_Enabled(t *testing.T) {
// stdout is at Info; the ring buffer is at Debug. Enabled is the union, so
// a Debug record must still be handled — that is how the admin panel can
// show debug lines the console does not.
logger, buf, stdout := newTeeLogger(t, slog.LevelDebug)
logger.Debug("debug only")
if entries := buf.Snapshot(); len(entries) != 1 {
t.Errorf("ring buffer has %d entries, want the debug record", len(entries))
}
if strings.Contains(stdout(), "debug only") {
t.Error("a Debug record reached the Info-level stdout handler")
}
}
func TestMultiHandler_RingLevelFiltersOutLowRecords(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelWarn)
logger.Info("below the ring threshold")
logger.Warn("at the ring threshold")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
if entries[0].Message != "at the ring threshold" {
t.Errorf("Message = %q, want the Warn record", entries[0].Message)
}
}
func TestMultiHandler_WithAttrs(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
logger.With("user_id", 42).Info("with attrs", "extra", "yes")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
var attrs map[string]any
if err := json.Unmarshal([]byte(entries[0].Attrs), &attrs); err != nil {
t.Fatalf("unmarshal attrs %q: %v", entries[0].Attrs, err)
}
// Both the WithAttrs-supplied attr and the per-record attr must survive.
if _, ok := attrs["user_id"]; !ok {
t.Errorf("attrs = %v, want user_id from WithAttrs", attrs)
}
if attrs["extra"] != "yes" {
t.Errorf("attrs[extra] = %v, want \"yes\"", attrs["extra"])
}
}
func TestMultiHandler_WithAttrs_DoesNotMutateParent(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
child := logger.With("scoped", "child")
child.Info("from child")
logger.Info("from parent")
entries := buf.Snapshot()
if len(entries) != 2 {
t.Fatalf("ring buffer has %d entries, want 2", len(entries))
}
// withAttrs copies into a fresh slice; the parent must not inherit them.
for _, e := range entries {
if e.Message == "from parent" && strings.Contains(e.Attrs, "scoped") {
t.Errorf("parent record picked up the child's attrs: %q", e.Attrs)
}
}
}
func TestMultiHandler_WithGroup(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
logger.WithGroup("req").Info("grouped", "id", "abc")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
var attrs map[string]any
if err := json.Unmarshal([]byte(entries[0].Attrs), &attrs); err != nil {
t.Fatalf("unmarshal attrs %q: %v", entries[0].Attrs, err)
}
if attrs["req.id"] != "abc" {
t.Errorf("attrs = %v, want the group-qualified key req.id", attrs)
}
}
func TestMultiHandler_NestedGroups(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
logger.WithGroup("outer").WithGroup("inner").Info("nested", "k", "v")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
var attrs map[string]any
if err := json.Unmarshal([]byte(entries[0].Attrs), &attrs); err != nil {
t.Fatalf("unmarshal attrs: %v", err)
}
if attrs["outer.inner.k"] != "v" {
t.Errorf("attrs = %v, want outer.inner.k", attrs)
}
}
func TestMultiHandler_NoAttrsLeavesAttrsEmpty(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
logger.Info("bare message")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
if entries[0].Attrs != "" {
t.Errorf("Attrs = %q for a record with no attributes, want empty", entries[0].Attrs)
}
}
// ─── RingBuffer.Subscribe ───────────────────────────────────────────────────
func TestRingBuffer_Subscribe_ReceivesWrites(t *testing.T) {
buf := NewRingBuffer(8)
ch, unsubscribe := buf.Subscribe()
defer unsubscribe()
buf.Write(LogEntry{Message: "first"})
select {
case got := <-ch:
if got.Message != "first" {
t.Errorf("Message = %q, want %q", got.Message, "first")
}
default:
t.Fatal("subscriber received nothing")
}
}
func TestRingBuffer_Subscribe_UnsubscribeStopsDelivery(t *testing.T) {
buf := NewRingBuffer(8)
ch, unsubscribe := buf.Subscribe()
unsubscribe()
buf.Write(LogEntry{Message: "after unsubscribe"})
select {
case got := <-ch:
t.Errorf("received %q after unsubscribing", got.Message)
default:
}
}
func TestRingBuffer_Subscribe_MultipleSubscribersEachGetACopy(t *testing.T) {
buf := NewRingBuffer(8)
chA, stopA := buf.Subscribe()
defer stopA()
chB, stopB := buf.Subscribe()
defer stopB()
buf.Write(LogEntry{Message: "fanned out"})
for i, ch := range []<-chan LogEntry{chA, chB} {
select {
case got := <-ch:
if got.Message != "fanned out" {
t.Errorf("subscriber %d got %q, want %q", i, got.Message, "fanned out")
}
default:
t.Errorf("subscriber %d received nothing", i)
}
}
}
func TestRingBuffer_Subscribe_SlowSubscriberDoesNotBlockWrites(t *testing.T) {
buf := NewRingBuffer(8)
_, unsubscribe := buf.Subscribe() // never drained
defer unsubscribe()
// The subscriber channel holds 64; writing well past that must not block
// the logging path — records are dropped for that subscriber instead.
done := make(chan struct{})
go func() {
for i := range 200 {
buf.Write(LogEntry{Message: "flood", Level: "INFO", Timestamp: string(rune('a' + i%26))})
}
close(done)
}()
select {
case <-done:
case <-t.Context().Done():
t.Fatal("Write blocked on a slow subscriber")
}
// The ring itself stays capped at its capacity.
if got := len(buf.Snapshot()); got != 8 {
t.Errorf("ring buffer holds %d entries, want its capacity of 8", got)
}
}
// ─── categorizeSource ───────────────────────────────────────────────────────
func TestCategorizeSource_NoPCIsServer(t *testing.T) {
// A record built without a caller PC cannot be attributed to a package.
if got := categorizeSource(slog.Record{}); got != "server" {
t.Errorf("categorizeSource with PC 0 = %q, want %q", got, "server")
}
}
func TestCategorizeSource_AttributesAdminPackage(t *testing.T) {
logger, buf, _ := newTeeLogger(t, slog.LevelDebug)
// This call site lives in Server/admin, so the runtime frame resolves to
// the admin category.
logger.Info("from the admin package")
entries := buf.Snapshot()
if len(entries) != 1 {
t.Fatalf("ring buffer has %d entries, want 1", len(entries))
}
if entries[0].Source != "admin" {
t.Errorf("Source = %q, want %q", entries[0].Source, "admin")
}
}
func TestMultiHandler_HandleReturnsNil(t *testing.T) {
var stdout bytes.Buffer
buf := NewRingBuffer(4)
h := NewMultiHandler(slog.NewTextHandler(&stdout, nil), buf, slog.LevelDebug)
// Logging must never fail the caller, so Handle always reports success.
rec := slog.Record{Level: slog.LevelInfo, Message: "direct"}
if err := h.Handle(context.Background(), rec); err != nil {
t.Errorf("Handle = %v, want nil", err)
}
if !h.Enabled(context.Background(), slog.LevelInfo) {
t.Error("Enabled(Info) = false")
}
}
+92
View File
@@ -0,0 +1,92 @@
package admin
import "testing"
// isSetupOriginAllowed guards the first-run setup endpoint, which creates the
// server owner. It had no coverage. The safe default matters most: an empty
// allowlist must deny, not allow.
func TestIsSetupOriginAllowed(t *testing.T) {
tests := []struct {
name string
origin string
allowed []string
want bool
}{
{
name: "empty allowlist denies",
origin: "https://app.example",
allowed: nil,
want: false,
},
{
name: "empty allowlist denies even an empty origin",
origin: "",
allowed: nil,
want: false,
},
{
name: "exact match allows",
origin: "https://app.example",
allowed: []string{"https://app.example"},
want: true,
},
{
name: "match is case-insensitive",
origin: "https://APP.example",
allowed: []string{"https://app.example"},
want: true,
},
{
name: "wildcard allows anything",
origin: "https://anywhere.example",
allowed: []string{"*"},
want: true,
},
{
name: "wildcard anywhere in the list allows",
origin: "https://anywhere.example",
allowed: []string{"https://app.example", "*"},
want: true,
},
{
name: "non-matching origin denied",
origin: "https://evil.example",
allowed: []string{"https://app.example"},
want: false,
},
{
name: "match against any list entry",
origin: "https://second.example",
allowed: []string{"https://first.example", "https://second.example"},
want: true,
},
{
name: "a suffix of an allowed origin is not a match",
origin: "https://evil-app.example",
allowed: []string{"https://app.example"},
want: false,
},
{
name: "a prefix of an allowed origin is not a match",
origin: "https://app.example.evil.test",
allowed: []string{"https://app.example"},
want: false,
},
{
name: "scheme must match too",
origin: "http://app.example",
allowed: []string{"https://app.example"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSetupOriginAllowed(tt.origin, tt.allowed); got != tt.want {
t.Errorf("isSetupOriginAllowed(%q, %v) = %v, want %v",
tt.origin, tt.allowed, got, tt.want)
}
})
}
}
+240
View File
@@ -0,0 +1,240 @@
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The /api/v1/blocks routes are mounted by MountDMRoutes but were never
// exercised: handleListBlocks, handleBlockUser and handleUnblockUser had no
// test hitting them, so the whole blocking feature was untested from the REST
// edge down to the database. These tests reuse the DM harness
// (newDMTestDB / buildDMRouter / dmCreateToken) since the routes share a mount.
// dmPut issues a PUT against the DM/blocks router. The existing helpers cover
// POST, GET and DELETE only.
func dmPut(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPut, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// decodeBlockedIDs pulls blocked_user_ids out of a GET /api/v1/blocks response.
func decodeBlockedIDs(t *testing.T, rr *httptest.ResponseRecorder) []int64 {
t.Helper()
var body struct {
BlockedUserIDs []int64 `json:"blocked_user_ids"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode blocked_user_ids from %q: %v", rr.Body.String(), err)
}
return body.BlockedUserIDs
}
// ─── PUT /api/v1/blocks/{userId} (handleBlockUser) ──────────────────────────
func TestBlockUser_Success(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4) // user id 2
rr := dmPut(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
blocked, err := database.IsBlocked(t.Context(), 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if !blocked {
t.Error("block was not persisted")
}
}
func TestBlockUser_SelfBlockRejected(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/1", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a self-block, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_UnknownTarget(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/9999", alice)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d for an unknown target, want 404 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_InvalidUserID(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/not-a-number", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a non-numeric userId, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
dmCreateToken(t, database, "bob", 4)
rr := dmPut(t, router, "/api/v1/blocks/1", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401 (body %q)", rr.Code, rr.Body.String())
}
}
// ─── DELETE /api/v1/blocks/{userId} (handleUnblockUser) ─────────────────────
func TestUnblockUser_Success(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4)
if rr := dmPut(t, router, "/api/v1/blocks/2", alice); rr.Code != http.StatusOK {
t.Fatalf("setup block failed: status %d", rr.Code)
}
rr := dmDelete(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
blocked, err := database.IsBlocked(t.Context(), 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if blocked {
t.Error("block survived the unblock request")
}
}
func TestUnblockUser_NotBlockedStillSucceeds(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4)
// Unblocking someone who was never blocked is a no-op, not a 404.
rr := dmDelete(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
}
func TestUnblockUser_InvalidUserID(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmDelete(t, router, "/api/v1/blocks/abc", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a non-numeric userId, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestUnblockUser_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
rr := dmDelete(t, router, "/api/v1/blocks/2", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401 (body %q)", rr.Code, rr.Body.String())
}
}
// ─── GET /api/v1/blocks (handleListBlocks) ──────────────────────────────────
func TestListBlocks_EmptyArray(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmGet(t, router, "/api/v1/blocks", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
// The service normalizes nil to an empty slice specifically so this
// serializes as [] rather than null.
if got := rr.Body.String(); !jsonContainsEmptyBlockList(got) {
t.Errorf("body = %q, want blocked_user_ids to be []", got)
}
if ids := decodeBlockedIDs(t, rr); len(ids) != 0 {
t.Errorf("blocked_user_ids = %v, want empty", ids)
}
}
func TestListBlocks_ReturnsBlockedIDs(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
bob := dmCreateToken(t, database, "bob", 4)
dmCreateToken(t, database, "carol", 4) // user id 3
for _, target := range []string{"2", "3"} {
if rr := dmPut(t, router, "/api/v1/blocks/"+target, alice); rr.Code != http.StatusOK {
t.Fatalf("setup block of %s failed: status %d", target, rr.Code)
}
}
rr := dmGet(t, router, "/api/v1/blocks", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
ids := decodeBlockedIDs(t, rr)
if len(ids) != 2 {
t.Fatalf("blocked_user_ids = %v, want 2 entries", ids)
}
// Blocks are per-user: bob sees none of alice's.
rr = dmGet(t, router, "/api/v1/blocks", bob)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d for bob, want 200", rr.Code)
}
if ids := decodeBlockedIDs(t, rr); len(ids) != 0 {
t.Errorf("bob's blocked_user_ids = %v, want empty", ids)
}
}
func TestListBlocks_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
rr := dmGet(t, router, "/api/v1/blocks", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401", rr.Code)
}
}
// jsonContainsEmptyBlockList reports whether the payload encodes an empty JSON
// array (not null) for blocked_user_ids.
func jsonContainsEmptyBlockList(body string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &raw); err != nil {
return false
}
return string(raw["blocked_user_ids"]) == "[]"
}
+15 -2
View File
@@ -3,14 +3,27 @@ package api
import (
"context"
"net/http"
"github.com/owncord/server/ws"
)
// HandleMetricsForTest exposes handleMetrics for use in external tests.
var HandleMetricsForTest = handleMetrics
// HandleLiveKitHealthForTest exposes handleLiveKitHealth for use in external tests.
// LiveKitHealthHandlerForTest exposes the real handleLiveKitHealth. Prefer it
// over HandleLiveKitHealthForTest, which only re-implements the same shape.
func LiveKitHealthHandlerForTest(hub *ws.Hub) http.HandlerFunc {
return handleLiveKitHealth(hub)
}
// HandleLiveKitHealthForTest re-implements handleLiveKitHealth against a
// caller-supplied health check.
//
// It does NOT exercise the production handler — the body below is a copy, so
// the two can drift and every test built on this hook would keep passing.
// It survives only because its callers predate LiveKitHealthHandlerForTest;
// new tests should use that instead, and these callers should migrate.
func HandleLiveKitHealthForTest(healthCheck func(context.Context) (bool, error)) http.HandlerFunc {
// Inline the logic since handleLiveKitHealth requires a *ws.Hub.
return func(w http.ResponseWriter, r *http.Request) {
ok, err := healthCheck(r.Context())
if ok {
+307
View File
@@ -0,0 +1,307 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/livekit/protocol/livekit"
"google.golang.org/protobuf/proto"
"github.com/owncord/server/api"
"github.com/owncord/server/config"
"github.com/owncord/server/ws"
)
// proxyWebSocket and copyWS carry every LiveKit signaling frame between the
// client and the media server, and neither had any coverage — the existing
// livekit_proxy_test.go stops at the path allowlist and Origin check, before
// the upgrade. handleLiveKitHealth was in the same position: its only "test"
// hook re-implemented the handler rather than calling it.
// echoWSBackend starts a WebSocket server that echoes every message it
// receives, and returns its ws:// URL.
func echoWSBackend(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
return
}
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
for {
typ, data, err := conn.Read(r.Context())
if err != nil {
return
}
if err := conn.Write(r.Context(), typ, data); err != nil {
return
}
}
}))
t.Cleanup(srv.Close)
return "ws://" + srv.Listener.Addr().String()
}
func TestLiveKitProxy_WebSocket_RoundTrip(t *testing.T) {
backend := echoWSBackend(t)
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
if err != nil {
t.Fatalf("dial through proxy: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
// Frontend → backend → frontend, through both copyWS goroutines.
if err := conn.Write(ctx, websocket.MessageText, []byte("signal")); err != nil {
t.Fatalf("write: %v", err)
}
typ, got, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
if typ != websocket.MessageText || string(got) != "signal" {
t.Errorf("echo = (%v, %q), want (text, \"signal\")", typ, got)
}
}
func TestLiveKitProxy_WebSocket_ForwardsBinary(t *testing.T) {
backend := echoWSBackend(t)
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
if err != nil {
t.Fatalf("dial through proxy: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
// LiveKit signaling is protobuf, so the binary opcode must survive the hop.
payload := []byte{0x00, 0x01, 0x02, 0xff}
if err := conn.Write(ctx, websocket.MessageBinary, payload); err != nil {
t.Fatalf("write: %v", err)
}
typ, got, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
if typ != websocket.MessageBinary || string(got) != string(payload) {
t.Errorf("echo = (%v, %v), want (binary, %v)", typ, got, payload)
}
}
func TestLiveKitProxy_WebSocket_PreservesQueryString(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
return
}
_ = conn.Write(r.Context(), websocket.MessageText, []byte("ok"))
conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
}))
t.Cleanup(srv.Close)
proxy := httptest.NewServer(api.NewLiveKitProxy("ws://"+srv.Listener.Addr().String(), []string{"*"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc?access_token=abc", nil)
if err != nil {
t.Fatalf("dial through proxy: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort
if _, _, err := conn.Read(ctx); err != nil {
t.Fatalf("read: %v", err)
}
// LiveKit carries the join token in the query string; dropping it would
// turn every voice join into an auth failure.
if gotQuery != "access_token=abc" {
t.Errorf("backend saw query %q, want %q", gotQuery, "access_token=abc")
}
}
func TestLiveKitProxy_WebSocket_BackendUnavailable(t *testing.T) {
// Nothing is listening on this port, so the backend dial must fail.
proxy := httptest.NewServer(api.NewLiveKitProxy("ws://127.0.0.1:1", []string{"*"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", nil)
if err == nil {
t.Fatal("dial succeeded despite an unreachable backend")
}
if resp == nil {
t.Fatal("no HTTP response returned for the failed upgrade")
}
defer resp.Body.Close() //nolint:errcheck // best-effort
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("status = %d, want 502", resp.StatusCode)
}
}
func TestLiveKitProxy_WebSocket_BlockedPathNotUpgraded(t *testing.T) {
backend := echoWSBackend(t)
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"*"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
// The allowlist runs before the upgrade branch, so an Upgrade header must
// not be a way around it.
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/twirp/whatever", nil)
if err == nil {
t.Fatal("upgrade to a blocked path succeeded")
}
if resp == nil {
t.Fatal("no HTTP response returned")
}
defer resp.Body.Close() //nolint:errcheck // best-effort
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403", resp.StatusCode)
}
}
func TestLiveKitProxy_WebSocket_CrossOriginRejected(t *testing.T) {
backend := echoWSBackend(t)
proxy := httptest.NewServer(api.NewLiveKitProxy(backend, []string{"https://allowed.example"}))
t.Cleanup(proxy.Close)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, resp, err := websocket.Dial(ctx, "ws://"+proxy.Listener.Addr().String()+"/rtc", &websocket.DialOptions{
HTTPHeader: http.Header{"Origin": []string{"https://evil.example"}},
})
if err == nil {
t.Fatal("upgrade from a disallowed origin succeeded")
}
if resp == nil {
t.Fatal("no HTTP response returned")
}
defer resp.Body.Close() //nolint:errcheck // best-effort
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403", resp.StatusCode)
}
}
// ─── handleLiveKitHealth (the real handler) ─────────────────────────────────
// hubWithLiveKit returns a Hub whose LiveKit client points at a stub room
// service replying with the supplied status.
func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if status != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"code":"internal","msg":"boom"}`))
return
}
body, _ := proto.Marshal(&livekit.ListRoomsResponse{})
w.Header().Set("Content-Type", "application/protobuf")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
hub := ws.NewHub(nil, nil, nil)
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "testkeytestkeytest",
LiveKitAPISecret: "testsecrettestsecrettestsecret",
LiveKitURL: "ws://" + srv.Listener.Addr().String(),
})
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub.SetLiveKit(lk)
return hub
}
func TestHandleLiveKitHealth_Healthy(t *testing.T) {
handler := api.LiveKitHealthHandlerForTest(hubWithLiveKit(t, http.StatusOK))
rr := httptest.NewRecorder()
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
var body struct {
Status string `json:"status"`
LiveKitReachable bool `json:"livekit_reachable"`
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "ok" || !body.LiveKitReachable {
t.Errorf("body = %+v, want status ok and reachable", body)
}
if body.Error != "" {
t.Errorf("error = %q on the healthy path, want it omitted", body.Error)
}
}
func TestHandleLiveKitHealth_Degraded(t *testing.T) {
handler := api.LiveKitHealthHandlerForTest(hubWithLiveKit(t, http.StatusInternalServerError))
rr := httptest.NewRecorder()
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 (body %q)", rr.Code, rr.Body.String())
}
var body struct {
Status string `json:"status"`
LiveKitReachable bool `json:"livekit_reachable"`
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "degraded" || body.LiveKitReachable {
t.Errorf("body = %+v, want status degraded and unreachable", body)
}
if body.Error == "" {
t.Error("error is empty on the degraded path; the reason should be surfaced")
}
}
func TestHandleLiveKitHealth_NotConfigured(t *testing.T) {
// A hub with no LiveKit client at all — the common case when voice is off.
handler := api.LiveKitHealthHandlerForTest(ws.NewHub(nil, nil, nil))
rr := httptest.NewRecorder()
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rr.Code)
}
if !strings.Contains(rr.Body.String(), "not configured") {
t.Errorf("body = %q, want it to name the missing configuration", rr.Body.String())
}
}
+217
View File
@@ -0,0 +1,217 @@
package db_test
import (
"context"
"testing"
)
// The user-blocking feature had no coverage at any layer (db, service, REST).
// These tests pin the db layer: idempotency of block/unblock, the directional
// nature of IsBlocked versus the symmetric IsEitherBlocked used by the DM
// authorization path, and the listing order.
func TestBlockUser_And_IsBlocked(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
blocked, err := database.IsBlocked(ctx, 1, 2)
if err != nil {
t.Fatalf("IsBlocked before block: %v", err)
}
if blocked {
t.Fatal("IsBlocked = true before any block was recorded")
}
if err := database.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
blocked, err = database.IsBlocked(ctx, 1, 2)
if err != nil {
t.Fatalf("IsBlocked after block: %v", err)
}
if !blocked {
t.Error("IsBlocked(1, 2) = false after 1 blocked 2")
}
// The block is directional: 2 has not blocked 1.
reverse, err := database.IsBlocked(ctx, 2, 1)
if err != nil {
t.Fatalf("IsBlocked reverse: %v", err)
}
if reverse {
t.Error("IsBlocked(2, 1) = true; block should be directional")
}
}
func TestBlockUser_Idempotent(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
// INSERT OR IGNORE — blocking twice must not error or duplicate the row.
for i := range 3 {
if err := database.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser call %d: %v", i+1, err)
}
}
ids, err := database.ListBlockedUsers(ctx, 1)
if err != nil {
t.Fatalf("ListBlockedUsers: %v", err)
}
if len(ids) != 1 {
t.Errorf("ListBlockedUsers len = %d after 3 identical blocks, want 1 (got %v)", len(ids), ids)
}
}
func TestUnblockUser(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
if err := database.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
if err := database.UnblockUser(ctx, 1, 2); err != nil {
t.Fatalf("UnblockUser: %v", err)
}
blocked, err := database.IsBlocked(ctx, 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if blocked {
t.Error("IsBlocked = true after UnblockUser")
}
}
func TestUnblockUser_NotBlockedIsNoOp(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
// Documented as idempotent: unblocking someone who was never blocked
// must succeed rather than report "not found".
if err := database.UnblockUser(ctx, 1, 2); err != nil {
t.Errorf("UnblockUser on a non-existent block: %v", err)
}
}
func TestIsEitherBlocked(t *testing.T) {
tests := []struct {
name string
blockerID int64
blockedID int64
queryA, queryB int64
wantEitherResult bool
}{
{"no block at all", 0, 0, 1, 2, false},
{"a blocked b, query (a,b)", 1, 2, 1, 2, true},
{"a blocked b, query (b,a)", 1, 2, 2, 1, true},
{"b blocked a, query (a,b)", 2, 1, 1, 2, true},
{"unrelated pair", 1, 2, 1, 3, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
seedBlockUser(t, database, 3, "carol")
if tt.blockerID != 0 {
if err := database.BlockUser(ctx, tt.blockerID, tt.blockedID); err != nil {
t.Fatalf("BlockUser: %v", err)
}
}
got, err := database.IsEitherBlocked(ctx, tt.queryA, tt.queryB)
if err != nil {
t.Fatalf("IsEitherBlocked: %v", err)
}
if got != tt.wantEitherResult {
t.Errorf("IsEitherBlocked(%d, %d) = %v, want %v",
tt.queryA, tt.queryB, got, tt.wantEitherResult)
}
})
}
}
func TestListBlockedUsers(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
seedBlockUser(t, database, 2, "bob")
seedBlockUser(t, database, 3, "carol")
seedBlockUser(t, database, 4, "dave")
empty, err := database.ListBlockedUsers(ctx, 1)
if err != nil {
t.Fatalf("ListBlockedUsers on empty: %v", err)
}
if len(empty) != 0 {
t.Errorf("ListBlockedUsers on empty = %v, want none", empty)
}
for _, id := range []int64{2, 3} {
if err := database.BlockUser(ctx, 1, id); err != nil {
t.Fatalf("BlockUser(1, %d): %v", id, err)
}
}
// A block by a different user must not leak into user 1's list.
if err := database.BlockUser(ctx, 4, 3); err != nil {
t.Fatalf("BlockUser(4, 3): %v", err)
}
ids, err := database.ListBlockedUsers(ctx, 1)
if err != nil {
t.Fatalf("ListBlockedUsers: %v", err)
}
if len(ids) != 2 {
t.Fatalf("ListBlockedUsers = %v, want 2 entries", ids)
}
seen := map[int64]bool{}
for _, id := range ids {
seen[id] = true
}
if !seen[2] || !seen[3] {
t.Errorf("ListBlockedUsers = %v, want it to contain 2 and 3", ids)
}
}
func TestBlockUser_SelfBlockIsSilentlyDropped(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
seedBlockUser(t, database, 1, "alice")
// Migration 012 carries CHECK (blocker_id != blocked_id), but the query is
// INSERT OR IGNORE, and OR IGNORE suppresses CHECK violations as well as
// uniqueness ones. So a self-block does not surface an error — it lands as
// a no-op. Callers that want to reject it must do so above this layer.
if err := database.BlockUser(ctx, 1, 1); err != nil {
t.Fatalf("BlockUser(1, 1) = %v; INSERT OR IGNORE should swallow the CHECK violation", err)
}
// What matters is that no row is written.
ids, err := database.ListBlockedUsers(ctx, 1)
if err != nil {
t.Fatalf("ListBlockedUsers: %v", err)
}
if len(ids) != 0 {
t.Errorf("ListBlockedUsers = %v after a self-block, want none", ids)
}
blocked, err := database.IsBlocked(ctx, 1, 1)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if blocked {
t.Error("IsBlocked(1, 1) = true; a self-block must never be recorded")
}
}
+233
View File
@@ -0,0 +1,233 @@
package db_test
import (
"context"
"testing"
"time"
)
// The events table backs cold-tier reconnect replay: when a client's last_seq
// falls out of the in-memory ring, the hub refills from here. GetMaxEventSeq
// (which seeds the hub's counter at startup) and PruneEventsOlderThan (the
// retention job) had no coverage, and neither did the channel filter that
// keeps a replay from leaking events for channels the client cannot see.
func TestPersistEvent_AndGetEventsSince(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
for seq := int64(1); seq <= 3; seq++ {
if err := database.PersistEvent(ctx, seq, "chat_message", 10, []byte(`{"n":1}`)); err != nil {
t.Fatalf("PersistEvent(%d): %v", seq, err)
}
}
all, err := database.GetEventsSince(ctx, 0, 100)
if err != nil {
t.Fatalf("GetEventsSince: %v", err)
}
if len(all) != 3 {
t.Fatalf("GetEventsSince(0) returned %d events, want 3", len(all))
}
for i, e := range all {
if e.Seq != int64(i+1) {
t.Errorf("event[%d].Seq = %d, want %d — replay depends on ascending order", i, e.Seq, i+1)
}
if e.EventType != "chat_message" {
t.Errorf("event[%d].EventType = %q", i, e.EventType)
}
if e.CreatedAt.IsZero() {
t.Errorf("event[%d].CreatedAt is zero; parseSQLiteTime failed to parse the row", i)
}
}
// afterSeq is exclusive.
tail, err := database.GetEventsSince(ctx, 2, 100)
if err != nil {
t.Fatalf("GetEventsSince(2): %v", err)
}
if len(tail) != 1 || tail[0].Seq != 3 {
t.Errorf("GetEventsSince(2) = %+v, want only seq 3", tail)
}
}
func TestGetEventsSince_RespectsLimit(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
for seq := int64(1); seq <= 10; seq++ {
if err := database.PersistEvent(ctx, seq, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
}
got, err := database.GetEventsSince(ctx, 0, 4)
if err != nil {
t.Fatalf("GetEventsSince: %v", err)
}
if len(got) != 4 {
t.Fatalf("returned %d events, want the 4-row limit", len(got))
}
if got[0].Seq != 1 || got[3].Seq != 4 {
t.Errorf("limited window = seq %d..%d, want 1..4", got[0].Seq, got[3].Seq)
}
}
func TestGetEventsSinceForChannels(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
// seq 1: global; 2: channel 10; 3: channel 20; 4: channel 30.
events := []struct {
seq int64
channelID int64
}{{1, 0}, {2, 10}, {3, 20}, {4, 30}}
for _, e := range events {
if err := database.PersistEvent(ctx, e.seq, "e", e.channelID, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
}
t.Run("no channels returns globals only", func(t *testing.T) {
got, err := database.GetEventsSinceForChannels(ctx, 0, nil, 100)
if err != nil {
t.Fatalf("GetEventsSinceForChannels: %v", err)
}
if len(got) != 1 || got[0].Seq != 1 {
t.Errorf("got %+v, want only the global event", got)
}
})
t.Run("visible channels plus globals", func(t *testing.T) {
got, err := database.GetEventsSinceForChannels(ctx, 0, []int64{10, 20}, 100)
if err != nil {
t.Fatalf("GetEventsSinceForChannels: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d events, want 3 (global + channels 10 and 20): %+v", len(got), got)
}
// Channel 30 is not visible to this client and must never appear.
for _, e := range got {
if e.ChannelID == 30 {
t.Errorf("replay leaked an event for channel 30: %+v", e)
}
}
})
t.Run("afterSeq is applied with the channel filter", func(t *testing.T) {
got, err := database.GetEventsSinceForChannels(ctx, 2, []int64{10, 20}, 100)
if err != nil {
t.Fatalf("GetEventsSinceForChannels: %v", err)
}
if len(got) != 1 || got[0].Seq != 3 {
t.Errorf("got %+v, want only seq 3", got)
}
})
t.Run("limit is applied", func(t *testing.T) {
got, err := database.GetEventsSinceForChannels(ctx, 0, []int64{10, 20}, 2)
if err != nil {
t.Fatalf("GetEventsSinceForChannels: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d events, want the 2-row limit", len(got))
}
})
}
func TestGetMaxEventSeq(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
// On an empty table MAX(seq) is NULL — the hub seeds its counter from this
// at startup, so it has to come back as 0 rather than an error.
got, err := database.GetMaxEventSeq(ctx)
if err != nil {
t.Fatalf("GetMaxEventSeq on empty: %v", err)
}
if got != 0 {
t.Errorf("GetMaxEventSeq on an empty table = %d, want 0", got)
}
for _, seq := range []int64{1, 7, 4} {
if err := database.PersistEvent(ctx, seq, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent(%d): %v", seq, err)
}
}
got, err = database.GetMaxEventSeq(ctx)
if err != nil {
t.Fatalf("GetMaxEventSeq: %v", err)
}
if got != 7 {
t.Errorf("GetMaxEventSeq = %d, want 7 (the highest, not the last inserted)", got)
}
}
func TestPruneEventsOlderThan(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
// created_at defaults to CURRENT_TIMESTAMP, so backdate the old rows
// explicitly in the same "2006-01-02 15:04:05" format the prune compares.
old := time.Now().UTC().Add(-48 * time.Hour).Format("2006-01-02 15:04:05")
for _, seq := range []int64{1, 2} {
if err := database.PersistEvent(ctx, seq, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
if _, err := database.ExecContext(ctx,
`UPDATE events SET created_at = ? WHERE seq = ?`, old, seq); err != nil {
t.Fatalf("backdate seq %d: %v", seq, err)
}
}
if err := database.PersistEvent(ctx, 3, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
deleted, err := database.PruneEventsOlderThan(ctx, time.Now().UTC().Add(-24*time.Hour))
if err != nil {
t.Fatalf("PruneEventsOlderThan: %v", err)
}
if deleted != 2 {
t.Errorf("deleted = %d, want 2", deleted)
}
remaining, err := database.GetEventsSince(ctx, 0, 100)
if err != nil {
t.Fatalf("GetEventsSince: %v", err)
}
if len(remaining) != 1 || remaining[0].Seq != 3 {
t.Errorf("remaining = %+v, want only the recent event", remaining)
}
}
func TestPruneEventsOlderThan_NothingToPrune(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
if err := database.PersistEvent(ctx, 1, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
deleted, err := database.PruneEventsOlderThan(ctx, time.Now().UTC().Add(-24*time.Hour))
if err != nil {
t.Fatalf("PruneEventsOlderThan: %v", err)
}
if deleted != 0 {
t.Errorf("deleted = %d, want 0 — a fresh event must survive the retention window", deleted)
}
}
func TestPersistEvent_DuplicateSeqRejected(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
if err := database.PersistEvent(ctx, 1, "e", 0, []byte(`{}`)); err != nil {
t.Fatalf("PersistEvent: %v", err)
}
// seq is the PRIMARY KEY; a duplicate would corrupt replay ordering, so it
// must surface as an error rather than silently overwrite.
if err := database.PersistEvent(ctx, 1, "e", 0, []byte(`{}`)); err == nil {
t.Error("PersistEvent accepted a duplicate seq")
}
}
+164
View File
@@ -0,0 +1,164 @@
package db_test
import (
"context"
"testing"
"time"
)
// Rate-limit lockouts are persisted so a brute-force lockout survives a server
// restart (migration 011). auth/ratelimit_test.go covers the in-memory limiter
// but never its persistence, so these four functions had no coverage — a gap
// with security consequences, since a lockout that fails to round-trip reopens
// the window it was meant to close.
func TestUpsertLockout_RoundTrip(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
expiry := time.Now().UTC().Add(15 * time.Minute).Truncate(time.Second)
if err := database.UpsertLockout(ctx, "ip:203.0.113.7", expiry); err != nil {
t.Fatalf("UpsertLockout: %v", err)
}
keys, expiries, err := database.LoadActiveLockouts(ctx)
if err != nil {
t.Fatalf("LoadActiveLockouts: %v", err)
}
if len(keys) != 1 || len(expiries) != 1 {
t.Fatalf("LoadActiveLockouts returned %d keys / %d expiries, want 1 each", len(keys), len(expiries))
}
if keys[0] != "ip:203.0.113.7" {
t.Errorf("key = %q, want %q", keys[0], "ip:203.0.113.7")
}
if !expiries[0].Equal(expiry) {
t.Errorf("expiry = %v, want %v", expiries[0], expiry)
}
}
func TestUpsertLockout_ReplacesExistingKey(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
first := time.Now().UTC().Add(5 * time.Minute).Truncate(time.Second)
later := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Second)
if err := database.UpsertLockout(ctx, "user:alice", first); err != nil {
t.Fatalf("UpsertLockout first: %v", err)
}
if err := database.UpsertLockout(ctx, "user:alice", later); err != nil {
t.Fatalf("UpsertLockout second: %v", err)
}
keys, expiries, err := database.LoadActiveLockouts(ctx)
if err != nil {
t.Fatalf("LoadActiveLockouts: %v", err)
}
if len(keys) != 1 {
t.Fatalf("got %d lockouts after re-upserting the same key, want 1: %v", len(keys), keys)
}
// An escalating lockout must extend, not duplicate or shorten.
if !expiries[0].Equal(later) {
t.Errorf("expiry = %v, want the later value %v", expiries[0], later)
}
}
func TestLoadActiveLockouts_ExcludesExpired(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
past := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second)
future := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Second)
if err := database.UpsertLockout(ctx, "expired", past); err != nil {
t.Fatalf("UpsertLockout expired: %v", err)
}
if err := database.UpsertLockout(ctx, "active", future); err != nil {
t.Fatalf("UpsertLockout active: %v", err)
}
keys, _, err := database.LoadActiveLockouts(ctx)
if err != nil {
t.Fatalf("LoadActiveLockouts: %v", err)
}
if len(keys) != 1 || keys[0] != "active" {
t.Errorf("LoadActiveLockouts = %v, want only [active]", keys)
}
}
func TestCleanupExpiredLockouts(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
future := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second)
for key, exp := range map[string]time.Time{
"stale-a": past,
"stale-b": past,
"live": future,
} {
if err := database.UpsertLockout(ctx, key, exp); err != nil {
t.Fatalf("UpsertLockout(%s): %v", key, err)
}
}
if err := database.CleanupExpiredLockouts(ctx); err != nil {
t.Fatalf("CleanupExpiredLockouts: %v", err)
}
var remaining int
row := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM rate_lockouts`)
if err := row.Scan(&remaining); err != nil {
t.Fatalf("count rate_lockouts: %v", err)
}
if remaining != 1 {
t.Errorf("%d rows left after cleanup, want 1 (only the unexpired one)", remaining)
}
keys, _, err := database.LoadActiveLockouts(ctx)
if err != nil {
t.Fatalf("LoadActiveLockouts: %v", err)
}
if len(keys) != 1 || keys[0] != "live" {
t.Errorf("LoadActiveLockouts = %v, want only [live]", keys)
}
}
func TestCleanupExpiredLockouts_EmptyTable(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.CleanupExpiredLockouts(context.Background()); err != nil {
t.Errorf("CleanupExpiredLockouts on an empty table: %v", err)
}
}
func TestDeleteLockout(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
future := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
if err := database.UpsertLockout(ctx, "ip:198.51.100.4", future); err != nil {
t.Fatalf("UpsertLockout: %v", err)
}
if err := database.UpsertLockout(ctx, "ip:198.51.100.5", future); err != nil {
t.Fatalf("UpsertLockout: %v", err)
}
if err := database.DeleteLockout(ctx, "ip:198.51.100.4"); err != nil {
t.Fatalf("DeleteLockout: %v", err)
}
keys, _, err := database.LoadActiveLockouts(ctx)
if err != nil {
t.Fatalf("LoadActiveLockouts: %v", err)
}
if len(keys) != 1 || keys[0] != "ip:198.51.100.5" {
t.Errorf("LoadActiveLockouts = %v, want only the untouched key", keys)
}
}
func TestDeleteLockout_UnknownKeyIsNoOp(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.DeleteLockout(context.Background(), "never-locked"); err != nil {
t.Errorf("DeleteLockout on an unknown key: %v", err)
}
}
+39
View File
@@ -0,0 +1,39 @@
package db_test
import (
"context"
"testing"
"github.com/owncord/server/db"
)
// newMigratedTestDB opens an in-memory database with the *real* embedded
// migration set applied, unlike newTestDB / newAdminTestDB / newVoiceTestDB
// which run a hand-maintained subset of the schema inline.
//
// Tests for tables introduced by later migrations (rate_lockouts in 011,
// user_blocks in 012, events in 014, plugins in 015) use this so they exercise
// the schema that actually ships rather than a copy that can drift from it.
func newMigratedTestDB(t *testing.T) *db.DB {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
return database
}
// seedBlockUser inserts a minimal user row with an explicit id so block tests
// satisfy the user_blocks foreign keys.
func seedBlockUser(t *testing.T, database *db.DB, id int64, username string) {
t.Helper()
_, err := database.ExecContext(context.Background(),
`INSERT INTO users (id, username, password) VALUES (?, ?, 'x')`, id, username)
if err != nil {
t.Fatalf("seed user %d: %v", id, err)
}
}
+322
View File
@@ -0,0 +1,322 @@
package db_test
import (
"bytes"
"context"
"database/sql"
"errors"
"testing"
)
// Plugin persistence (migration 015) and the per-plugin KV namespace had no
// coverage. The KV namespace is the isolation boundary between plugins — the
// primary key is (plugin_id, key), so a plugin cannot name another plugin's
// namespace — and that property is worth pinning explicitly.
func installTestPlugin(t *testing.T, database interface {
InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error)
}, name string) int64 {
t.Helper()
id, err := database.InstallPlugin(context.Background(), name, "1.0.0", `{"name":"`+name+`"}`)
if err != nil {
t.Fatalf("InstallPlugin(%s): %v", name, err)
}
return id
}
func TestInstallPlugin_AndGetPlugin(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if id == 0 {
t.Fatal("InstallPlugin returned id 0")
}
got, err := database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Name != "hello" {
t.Errorf("Name = %q, want %q", got.Name, "hello")
}
if got.Version != "1.0.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0.0")
}
if got.Enabled {
t.Error("Enabled = true; a freshly installed plugin must default to disabled")
}
if got.InstalledAt.IsZero() {
t.Error("InstalledAt is zero; scanPluginRow should have parsed the timestamp")
}
}
func TestInstallPlugin_ReinstallUpdatesInPlace(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
first := installTestPlugin(t, database, "hello")
// ON CONFLICT(name) DO UPDATE — a reinstall must upgrade the existing row
// rather than create a second one, and must return the same id (the path
// where LastInsertId is 0 and the code falls back to a lookup by name).
second, err := database.InstallPlugin(ctx, "hello", "2.0.0", `{"name":"hello","v":2}`)
if err != nil {
t.Fatalf("InstallPlugin reinstall: %v", err)
}
if second != first {
t.Errorf("reinstall returned id %d, want the original %d", second, first)
}
got, err := database.GetPlugin(ctx, first)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Version != "2.0.0" {
t.Errorf("Version = %q after reinstall, want %q", got.Version, "2.0.0")
}
all, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(all) != 1 {
t.Errorf("ListPlugins len = %d after reinstall, want 1", len(all))
}
}
func TestEnableDisablePlugin(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if err := database.EnablePlugin(ctx, id); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
got, err := database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if !got.Enabled {
t.Error("Enabled = false after EnablePlugin")
}
if err := database.DisablePlugin(ctx, id); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
got, err = database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Enabled {
t.Error("Enabled = true after DisablePlugin")
}
}
func TestGetPluginByName(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
got, err := database.GetPluginByName(ctx, "hello")
if err != nil {
t.Fatalf("GetPluginByName: %v", err)
}
if got.ID != id {
t.Errorf("ID = %d, want %d", got.ID, id)
}
_, err = database.GetPluginByName(ctx, "does-not-exist")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPluginByName on a missing name = %v, want sql.ErrNoRows", err)
}
}
func TestGetPlugin_Missing(t *testing.T) {
database := newMigratedTestDB(t)
_, err := database.GetPlugin(context.Background(), 4242)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPlugin on a missing id = %v, want sql.ErrNoRows", err)
}
}
func TestUninstallPlugin_CascadesKV(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if err := database.PluginKVSet(ctx, id, "k", []byte("v")); err != nil {
t.Fatalf("PluginKVSet: %v", err)
}
if err := database.UninstallPlugin(ctx, id); err != nil {
t.Fatalf("UninstallPlugin: %v", err)
}
if _, err := database.GetPlugin(ctx, id); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPlugin after uninstall = %v, want sql.ErrNoRows", err)
}
// plugin_kv has ON DELETE CASCADE; an uninstall must not leave the removed
// plugin's stored data behind for whatever reinstalls under that id.
var leftover int
row := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM plugin_kv WHERE plugin_id = ?`, id)
if err := row.Scan(&leftover); err != nil {
t.Fatalf("count plugin_kv: %v", err)
}
if leftover != 0 {
t.Errorf("%d plugin_kv rows survived the uninstall, want 0", leftover)
}
}
func TestListPlugins_OrderedByName(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
empty, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins on empty: %v", err)
}
if len(empty) != 0 {
t.Errorf("ListPlugins on empty = %v, want none", empty)
}
for _, name := range []string{"zeta", "alpha", "mid"} {
installTestPlugin(t, database, name)
}
got, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
want := []string{"alpha", "mid", "zeta"}
if len(got) != len(want) {
t.Fatalf("ListPlugins len = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].Name != w {
t.Errorf("ListPlugins[%d].Name = %q, want %q", i, got[i].Name, w)
}
}
}
func TestPluginKV_SetGetDelete(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if _, err := database.PluginKVGet(ctx, id, "absent"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("PluginKVGet on a missing key = %v, want sql.ErrNoRows", err)
}
if err := database.PluginKVSet(ctx, id, "greeting", []byte("hi")); err != nil {
t.Fatalf("PluginKVSet: %v", err)
}
v, err := database.PluginKVGet(ctx, id, "greeting")
if err != nil {
t.Fatalf("PluginKVGet: %v", err)
}
if !bytes.Equal(v, []byte("hi")) {
t.Errorf("value = %q, want %q", v, "hi")
}
// ON CONFLICT(plugin_id, key) DO UPDATE — a second Set overwrites.
if err := database.PluginKVSet(ctx, id, "greeting", []byte("hello again")); err != nil {
t.Fatalf("PluginKVSet overwrite: %v", err)
}
v, err = database.PluginKVGet(ctx, id, "greeting")
if err != nil {
t.Fatalf("PluginKVGet after overwrite: %v", err)
}
if !bytes.Equal(v, []byte("hello again")) {
t.Errorf("value = %q after overwrite, want %q", v, "hello again")
}
if err := database.PluginKVDelete(ctx, id, "greeting"); err != nil {
t.Fatalf("PluginKVDelete: %v", err)
}
if _, err := database.PluginKVGet(ctx, id, "greeting"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("PluginKVGet after delete = %v, want sql.ErrNoRows", err)
}
}
func TestPluginKV_NamespacesAreIsolated(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
a := installTestPlugin(t, database, "plugin-a")
b := installTestPlugin(t, database, "plugin-b")
if err := database.PluginKVSet(ctx, a, "secret", []byte("a-value")); err != nil {
t.Fatalf("PluginKVSet a: %v", err)
}
if err := database.PluginKVSet(ctx, b, "secret", []byte("b-value")); err != nil {
t.Fatalf("PluginKVSet b: %v", err)
}
// Same key, different namespaces — neither plugin can read or clobber the
// other's value. This is the whole isolation guarantee of the KV store.
got, err := database.PluginKVGet(ctx, a, "secret")
if err != nil {
t.Fatalf("PluginKVGet a: %v", err)
}
if !bytes.Equal(got, []byte("a-value")) {
t.Errorf("plugin-a read %q, want %q", got, "a-value")
}
if err := database.PluginKVDelete(ctx, a, "secret"); err != nil {
t.Fatalf("PluginKVDelete a: %v", err)
}
got, err = database.PluginKVGet(ctx, b, "secret")
if err != nil {
t.Fatalf("PluginKVGet b after deleting a's key: %v", err)
}
if !bytes.Equal(got, []byte("b-value")) {
t.Errorf("plugin-b value = %q after plugin-a deleted its own key, want %q", got, "b-value")
}
}
func TestPluginKVScan(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
other := installTestPlugin(t, database, "other")
for k, v := range map[string]string{
"cfg:a": "1",
"cfg:b": "2",
"state:x": "3",
} {
if err := database.PluginKVSet(ctx, id, k, []byte(v)); err != nil {
t.Fatalf("PluginKVSet(%s): %v", k, err)
}
}
if err := database.PluginKVSet(ctx, other, "cfg:a", []byte("nope")); err != nil {
t.Fatalf("PluginKVSet other: %v", err)
}
got, err := database.PluginKVScan(ctx, id, "cfg:", 100)
if err != nil {
t.Fatalf("PluginKVScan: %v", err)
}
if len(got) != 2 {
t.Fatalf("PluginKVScan returned %d entries, want 2: %v", len(got), got)
}
if !bytes.Equal(got["cfg:a"], []byte("1")) || !bytes.Equal(got["cfg:b"], []byte("2")) {
t.Errorf("PluginKVScan = %v, want cfg:a=1 and cfg:b=2 from this plugin only", got)
}
limited, err := database.PluginKVScan(ctx, id, "cfg:", 1)
if err != nil {
t.Fatalf("PluginKVScan with limit: %v", err)
}
if len(limited) != 1 {
t.Errorf("PluginKVScan with limit 1 returned %d entries, want 1", len(limited))
}
none, err := database.PluginKVScan(ctx, id, "nomatch:", 100)
if err != nil {
t.Fatalf("PluginKVScan no match: %v", err)
}
if len(none) != 0 {
t.Errorf("PluginKVScan with a non-matching prefix = %v, want empty", none)
}
}
+1 -1
View File
@@ -33,6 +33,7 @@ require (
golang.org/x/crypto v0.52.0
golang.org/x/mod v0.38.0
golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.54.0
)
@@ -136,7 +137,6 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
+44
View File
@@ -36,3 +36,47 @@ func TestHandlerAddsReqID(t *testing.T) {
t.Errorf("expected req_id to survive With(): %s", buf.String())
}
}
// TestHandlerSurvivesWithGroup covers the WithGroup re-wrap and pins the known
// nesting caveat the WithGroup doc comment flags.
//
// Enrichment survives the re-wrap, but because Handle calls r.AddAttrs *after*
// the inner handler has opened the group, req_id lands as "http.req_id" rather
// than at the record's top level. That is only harmless while no logger-level
// groups are opened in production code — which is true today, and is exactly
// the condition the source comment says to revisit. If someone introduces
// slog groups, this test is what tells them log searches for a bare `req_id`
// will stop matching.
func TestHandlerSurvivesWithGroup(t *testing.T) {
var buf bytes.Buffer
log := slog.New(New(slog.NewTextHandler(&buf, nil)))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey, "req-group-1")
log.WithGroup("http").InfoContext(ctx, "grouped", "status", 200)
out := buf.String()
if !strings.Contains(out, "req-group-1") {
t.Errorf("req_id was dropped by WithGroup(): %s", out)
}
if !strings.Contains(out, "http.req_id=req-group-1") {
t.Errorf("current behaviour is to nest req_id under the group; got: %s", out)
}
if !strings.Contains(out, "http.status=200") {
t.Errorf("expected the group to still apply to record attrs: %s", out)
}
}
// TestHandlerEnabledDelegates confirms the wrapper does not widen or narrow the
// inner handler's level.
func TestHandlerEnabledDelegates(t *testing.T) {
var buf bytes.Buffer
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
h := New(inner)
if h.Enabled(context.Background(), slog.LevelInfo) {
t.Error("Enabled(Info) = true for a Warn-level inner handler")
}
if !h.Enabled(context.Background(), slog.LevelError) {
t.Error("Enabled(Error) = false for a Warn-level inner handler")
}
}
+527
View File
@@ -0,0 +1,527 @@
// Registry lifecycle tests for the default (non-wazero) build.
//
// registry.go is the largest source file in the plugin package and its
// lifecycle half — Sink, activate, EnablePlugin, DisablePlugin,
// UninstallPlugin, List, UITabBindings — had no coverage. The wazero-tagged
// build has its own activation tests in sandbox_wazero_test.go; what is pinned
// here is the behaviour that holds *without* a runtime: enabling must roll the
// store flag back when activation fails, disabling must drop command bindings,
// and uninstalling must remove the on-disk directory so the plugin is not
// resurrected by the next LoadAll.
package plugin
import (
"archive/zip"
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
)
// newRegistryWithDir builds a registry backed by a real in-memory database and
// a temp plugin directory, and returns both.
func newRegistryWithDir(t *testing.T) (*Registry, PluginStore, string) {
t.Helper()
dir := t.TempDir()
store := openPluginTestDB(t)
r, err := NewRegistry(Config{Directory: dir, Store: store})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { _ = r.Close(context.Background()) })
return r, store, dir
}
// writePluginDir lays a minimal valid plugin out on disk and returns its path.
func writePluginDir(t *testing.T, root, name, manifestJSON string) string {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifestJSON), 0o600); err != nil {
t.Fatalf("write plugin.json: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name+".wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o600); err != nil {
t.Fatalf("write wasm: %v", err)
}
return dir
}
func simpleManifest(name string) string {
return `{"name":"` + name + `","version":"1.0.0","entrypoint":"` + name + `.wasm","permissions":["storage"]}`
}
// ─── NewRegistry / Sink ─────────────────────────────────────────────────────
func TestNewRegistry_RequiresStore(t *testing.T) {
if _, err := NewRegistry(Config{Directory: t.TempDir()}); err == nil {
t.Error("NewRegistry with a nil Store succeeded; want an error")
}
}
func TestRegistry_Sink(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
sink := r.Sink()
if sink == nil {
t.Fatal("Sink() = nil; the hub dereferences this on every broadcast")
}
if r.Sink() != sink {
t.Error("Sink() returned a different EventSink on the second call; it must be stable")
}
}
// ─── LoadAll / List ─────────────────────────────────────────────────────────
func TestRegistry_LoadAll_RegistersDiscoveredPlugins(t *testing.T) {
r, store, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
writePluginDir(t, dir, "beta", simpleManifest("beta"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
list := r.List()
if len(list) != 2 {
t.Fatalf("List() has %d entries after LoadAll, want 2", len(list))
}
for _, inst := range list {
if inst.Enabled {
t.Errorf("plugin %q is enabled straight after LoadAll; installs must default to disabled", inst.Manifest.Name)
}
}
rows, err := store.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(rows) != 2 {
t.Errorf("store has %d rows, want 2 — LoadAll must persist discovered manifests", len(rows))
}
}
func TestRegistry_LoadAll_RemovesStaleStagingDirs(t *testing.T) {
r, _, dir := newRegistryWithDir(t)
// A crash mid-InstallFromZip leaves a ".install-XXXX" directory behind.
stale := filepath.Join(dir, ".install-abc123")
if err := os.MkdirAll(stale, 0o750); err != nil {
t.Fatalf("mkdir stale: %v", err)
}
if err := r.LoadAll(context.Background()); err != nil {
t.Fatalf("LoadAll: %v", err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale staging dir survived LoadAll (stat err = %v)", err)
}
}
func TestRegistry_List_IsASnapshot(t *testing.T) {
r, _, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
first := r.List()
if len(first) != 1 {
t.Fatalf("List() = %d entries, want 1", len(first))
}
// Mutating the returned slice must not affect the registry.
first[0] = nil
second := r.List()
if len(second) != 1 || second[0] == nil {
t.Error("mutating the slice returned by List() corrupted the registry's own state")
}
}
// ─── activate (default build) ───────────────────────────────────────────────
func TestRegistry_Activate_WithoutRuntime(t *testing.T) {
r, _, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
inst := r.List()[0]
err := r.activate(ctx, inst)
if !errors.Is(err, ErrRuntimeUnavailable) {
t.Errorf("activate without a runtime = %v, want ErrRuntimeUnavailable", err)
}
}
func TestRegistry_Activate_AfterClose(t *testing.T) {
r, _, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
inst := r.List()[0]
if err := r.Close(ctx); err != nil {
t.Fatalf("Close: %v", err)
}
// Close nils runtimePlatform under the lock; activate must observe that
// and refuse rather than call into a torn-down runtime.
if err := r.activate(ctx, inst); !errors.Is(err, ErrRuntimeUnavailable) {
t.Errorf("activate after Close = %v, want ErrRuntimeUnavailable", err)
}
if got := r.List(); len(got) != 0 {
t.Errorf("List() = %d entries after Close, want 0", len(got))
}
}
// ─── EnablePlugin ───────────────────────────────────────────────────────────
func TestRegistry_EnablePlugin_RollsBackWhenActivationFails(t *testing.T) {
r, store, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
inst := r.List()[0]
// The default build has no runtime, so activation always fails. What
// matters is that the failure leaves no half-enabled state behind.
err := r.EnablePlugin(ctx, inst.ID)
if !errors.Is(err, ErrRuntimeUnavailable) {
t.Fatalf("EnablePlugin = %v, want ErrRuntimeUnavailable", err)
}
if inst.Enabled {
t.Error("in-memory Enabled flag stayed true after a failed activation")
}
row, err := store.GetPlugin(ctx, inst.ID)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if row.Enabled {
t.Error("store row stayed enabled after a failed activation; the rollback did not run")
}
}
func TestRegistry_EnablePlugin_UnknownID(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
if err := r.EnablePlugin(context.Background(), 999); !errors.Is(err, ErrPluginNotFound) {
t.Errorf("EnablePlugin on an unknown id = %v, want ErrPluginNotFound", err)
}
}
// ─── DisablePlugin ──────────────────────────────────────────────────────────
func TestRegistry_DisablePlugin_ClearsFlagAndCommands(t *testing.T) {
r, store, dir := newRegistryWithDir(t)
ctx := context.Background()
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
inst := r.List()[0]
// Simulate an activated plugin that owns a command binding.
r.mu.Lock()
inst.Enabled = true
r.commands["greet"] = inst
r.mu.Unlock()
if err := r.DisablePlugin(ctx, inst.ID); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
if inst.Enabled {
t.Error("Enabled flag survived DisablePlugin")
}
r.mu.RLock()
_, stillBound := r.commands["greet"]
r.mu.RUnlock()
if stillBound {
t.Error("command binding survived DisablePlugin; dispatch would still route into a torn-down plugin")
}
row, err := store.GetPlugin(ctx, inst.ID)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if row.Enabled {
t.Error("store row stayed enabled after DisablePlugin")
}
}
func TestRegistry_DisablePlugin_UnknownIDIsNoOp(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
// The store UPDATE matches no rows and the in-memory lookup misses; this
// must not error, so an admin can disable an already-removed plugin.
if err := r.DisablePlugin(context.Background(), 999); err != nil {
t.Errorf("DisablePlugin on an unknown id = %v, want nil", err)
}
}
// ─── UninstallPlugin ────────────────────────────────────────────────────────
func TestRegistry_UninstallPlugin_RemovesRowAndDirectory(t *testing.T) {
r, store, dir := newRegistryWithDir(t)
ctx := context.Background()
pluginDir := writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
inst := r.List()[0]
if err := r.UninstallPlugin(ctx, inst.ID); err != nil {
t.Fatalf("UninstallPlugin: %v", err)
}
if got := r.List(); len(got) != 0 {
t.Errorf("List() = %d entries after uninstall, want 0", len(got))
}
rows, err := store.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(rows) != 0 {
t.Errorf("store has %d rows after uninstall, want 0", len(rows))
}
// The on-disk removal is what stops the next LoadAll from reinstalling it.
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Errorf("plugin directory survived uninstall (stat err = %v)", err)
}
if err := r.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll after uninstall: %v", err)
}
if got := r.List(); len(got) != 0 {
t.Errorf("uninstalled plugin was resurrected by LoadAll: %d entries", len(got))
}
}
func TestRegistry_UninstallPlugin_UnknownID(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
if err := r.UninstallPlugin(context.Background(), 999); err != nil {
t.Errorf("UninstallPlugin on an unknown id = %v, want nil", err)
}
}
// ─── UITabBindings ──────────────────────────────────────────────────────────
func TestRegistry_UITabBindings(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
if got := r.UITabBindings(); len(got) != 0 {
t.Errorf("UITabBindings() = %v on a fresh registry, want empty", got)
}
binding := UITabBinding{
PluginID: 7,
PluginName: "alpha",
Tab: UITab{ID: "main", Label: "Alpha", Asset: "index.html"},
}
r.mu.Lock()
r.uiTabs = append(r.uiTabs, binding)
r.mu.Unlock()
got := r.UITabBindings()
if len(got) != 1 || got[0].PluginName != "alpha" {
t.Fatalf("UITabBindings() = %+v, want the one registered binding", got)
}
// The returned slice is a copy — the client bridge must not be able to
// rewrite the registry's bindings through it.
got[0].PluginName = "mutated"
if again := r.UITabBindings(); again[0].PluginName != "alpha" {
t.Errorf("UITabBindings() returned the backing array; caller mutation leaked as %q", again[0].PluginName)
}
}
// ─── InstallFromZip ─────────────────────────────────────────────────────────
// buildZip assembles an in-memory zip from name→content pairs.
func buildZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range files {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func TestRegistry_InstallFromZip_Success(t *testing.T) {
r, store, dir := newRegistryWithDir(t)
ctx := context.Background()
zipBytes := buildZip(t, map[string]string{
"plugin.json": simpleManifest("zipped"),
"zipped.wasm": "\x00asm\x01\x00\x00\x00",
})
name, err := r.InstallFromZip(ctx, zipBytes)
if err != nil {
t.Fatalf("InstallFromZip: %v", err)
}
if name != "zipped" {
t.Errorf("name = %q, want %q", name, "zipped")
}
if _, err := os.Stat(filepath.Join(dir, "zipped", "plugin.json")); err != nil {
t.Errorf("plugin was not staged into the plugin directory: %v", err)
}
rows, err := store.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(rows) != 1 || rows[0].Name != "zipped" {
t.Errorf("store rows = %+v, want one row named zipped", rows)
}
// No staging directory may be left behind on the success path.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if len(e.Name()) > 9 && e.Name()[:9] == ".install-" {
t.Errorf("staging dir %q survived a successful install", e.Name())
}
}
}
func TestRegistry_InstallFromZip_Rejections(t *testing.T) {
tests := []struct {
name string
files map[string]string
}{
{
name: "missing plugin.json",
files: map[string]string{"stray.wasm": "\x00asm\x01\x00\x00\x00"},
},
{
name: "path traversal entry",
files: map[string]string{
"../escape.txt": "nope",
"plugin.json": simpleManifest("evil"),
"evil.wasm": "\x00asm\x01\x00\x00\x00",
},
},
{
name: "entrypoint missing from archive",
files: map[string]string{
"plugin.json": simpleManifest("ghost"),
},
},
{
name: "unparseable manifest",
files: map[string]string{
"plugin.json": `{"name":"bad"`,
"bad.wasm": "\x00asm\x01\x00\x00\x00",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _, dir := newRegistryWithDir(t)
_, err := r.InstallFromZip(context.Background(), buildZip(t, tt.files))
if err == nil {
t.Fatal("InstallFromZip succeeded; want a rejection")
}
// Every rejection path must clean its staging directory up.
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatalf("ReadDir: %v", readErr)
}
for _, e := range entries {
if len(e.Name()) > 9 && e.Name()[:9] == ".install-" {
t.Errorf("staging dir %q survived a rejected install", e.Name())
}
}
})
}
}
func TestRegistry_InstallFromZip_NotConfigured(t *testing.T) {
store := openPluginTestDB(t)
r, err := NewRegistry(Config{Store: store}) // no Directory
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { _ = r.Close(context.Background()) })
if _, err := r.InstallFromZip(context.Background(), []byte("whatever")); err == nil {
t.Error("InstallFromZip with no plugin directory succeeded; want an error")
}
}
func TestRegistry_InstallFromZip_OversizeRejected(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
oversize := make([]byte, maxZipBytes+1)
if _, err := r.InstallFromZip(context.Background(), oversize); err == nil {
t.Error("InstallFromZip accepted a zip over maxZipBytes")
}
}
func TestRegistry_InstallFromZip_InvalidArchive(t *testing.T) {
r, _, _ := newRegistryWithDir(t)
if _, err := r.InstallFromZip(context.Background(), []byte("this is not a zip")); err == nil {
t.Error("InstallFromZip accepted a non-zip payload")
}
}
// ─── bytesReaderAt ──────────────────────────────────────────────────────────
func TestBytesReaderAt(t *testing.T) {
data := bytesReaderAt("hello world")
buf := make([]byte, 5)
n, err := data.ReadAt(buf, 0)
if err != nil || n != 5 || string(buf) != "hello" {
t.Errorf("ReadAt(0) = (%d, %v, %q), want (5, nil, \"hello\")", n, err, buf)
}
// A short read at the tail reports io.EOF alongside the bytes it managed
// to copy, which is what archive/zip expects.
tail := make([]byte, 10)
n, err = data.ReadAt(tail, 6)
if n != 5 || err == nil {
t.Errorf("ReadAt(6) = (%d, %v), want (5, io.EOF)", n, err)
}
if string(tail[:n]) != "world" {
t.Errorf("tail = %q, want %q", tail[:n], "world")
}
if _, err := data.ReadAt(buf, -1); err == nil {
t.Error("ReadAt with a negative offset succeeded; want io.EOF")
}
if _, err := data.ReadAt(buf, 999); err == nil {
t.Error("ReadAt past the end succeeded; want io.EOF")
}
}
+173
View File
@@ -0,0 +1,173 @@
package service
import (
"context"
"errors"
"testing"
"github.com/owncord/server/db"
)
// BlockService had no coverage at all. Its job is the validation layer above
// db.BlockUser — which is INSERT OR IGNORE and therefore silently accepts a
// self-block — so these tests concentrate on the rejections.
func newBlockService(t *testing.T) (*BlockService, *db.DB) {
t.Helper()
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
return NewBlockService(database), database
}
func TestBlockService_BlockUser(t *testing.T) {
svc, database := newBlockService(t)
ctx := context.Background()
if err := svc.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
blocked, err := database.IsBlocked(ctx, 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if !blocked {
t.Error("block was not persisted")
}
}
func TestBlockService_BlockUser_Rejections(t *testing.T) {
tests := []struct {
name string
blockerID int64
targetID int64
wantErr error
}{
{"zero target", 1, 0, ErrBadRequest},
{"negative target", 1, -5, ErrBadRequest},
{"self block", 1, 1, ErrBadRequest},
{"unknown target", 1, 9999, ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, database := newBlockService(t)
ctx := context.Background()
err := svc.BlockUser(ctx, tt.blockerID, tt.targetID)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("BlockUser(%d, %d) = %v, want %v",
tt.blockerID, tt.targetID, err, tt.wantErr)
}
// A rejected block must not write anything.
ids, listErr := database.ListBlockedUsers(ctx, tt.blockerID)
if listErr != nil {
t.Fatalf("ListBlockedUsers: %v", listErr)
}
if len(ids) != 0 {
t.Errorf("a rejected block still wrote %v", ids)
}
})
}
}
func TestBlockService_BlockUser_Idempotent(t *testing.T) {
svc, database := newBlockService(t)
ctx := context.Background()
for i := range 2 {
if err := svc.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser call %d: %v", i+1, err)
}
}
ids, err := database.ListBlockedUsers(ctx, 1)
if err != nil {
t.Fatalf("ListBlockedUsers: %v", err)
}
if len(ids) != 1 {
t.Errorf("ListBlockedUsers = %v after blocking twice, want one entry", ids)
}
}
func TestBlockService_UnblockUser(t *testing.T) {
svc, database := newBlockService(t)
ctx := context.Background()
if err := svc.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
if err := svc.UnblockUser(ctx, 1, 2); err != nil {
t.Fatalf("UnblockUser: %v", err)
}
blocked, err := database.IsBlocked(ctx, 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if blocked {
t.Error("block survived UnblockUser")
}
}
func TestBlockService_UnblockUser_Rejections(t *testing.T) {
svc, _ := newBlockService(t)
ctx := context.Background()
for _, targetID := range []int64{0, -1} {
if err := svc.UnblockUser(ctx, 1, targetID); !errors.Is(err, ErrBadRequest) {
t.Errorf("UnblockUser(1, %d) = %v, want ErrBadRequest", targetID, err)
}
}
// Unlike BlockUser, UnblockUser does not verify the target exists — an
// unblock of a never-blocked (or deleted) user is a successful no-op.
if err := svc.UnblockUser(ctx, 1, 9999); err != nil {
t.Errorf("UnblockUser on an unknown user = %v, want nil (no-op)", err)
}
}
func TestBlockService_ListBlocked(t *testing.T) {
svc, database := newBlockService(t)
ctx := context.Background()
seedUser(t, database, &db.User{ID: 3, Username: "carol"})
// Never nil — the REST layer serializes this straight to JSON, and a nil
// slice would emit `null` where clients expect `[]`.
got, err := svc.ListBlocked(ctx, 1)
if err != nil {
t.Fatalf("ListBlocked: %v", err)
}
if got == nil {
t.Fatal("ListBlocked returned nil; want an empty slice so it marshals as []")
}
if len(got) != 0 {
t.Errorf("ListBlocked = %v on a fresh user, want empty", got)
}
if err := svc.BlockUser(ctx, 1, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
if err := svc.BlockUser(ctx, 1, 3); err != nil {
t.Fatalf("BlockUser: %v", err)
}
got, err = svc.ListBlocked(ctx, 1)
if err != nil {
t.Fatalf("ListBlocked: %v", err)
}
if len(got) != 2 {
t.Fatalf("ListBlocked = %v, want 2 entries", got)
}
// Another user's blocks must not appear.
other, err := svc.ListBlocked(ctx, 2)
if err != nil {
t.Fatalf("ListBlocked for user 2: %v", err)
}
if len(other) != 0 {
t.Errorf("ListBlocked(2) = %v, want empty", other)
}
}
@@ -0,0 +1,82 @@
package service
import (
"context"
"errors"
"testing"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// PermissionService.RequireChannelAccess had no coverage. It is the single
// authorization gate that splits DM channels (membership check) from regular
// channels (role + override check); a wrong branch here either leaks a DM to a
// non-participant or locks legitimate users out of a channel.
func TestRequireChannelAccess_RegularChannel(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
ctx := context.Background()
// The seeded Member role has SendMessages but not ManageChannels.
if err := svc.RequireChannelAccess(ctx, 1, "text", 10, permissions.SendMessages); err != nil {
t.Errorf("RequireChannelAccess with a granted permission = %v, want nil", err)
}
err := svc.RequireChannelAccess(ctx, 1, "text", 10, permissions.ManageChannels)
if !errors.Is(err, permissions.ErrPermissionDenied) {
t.Errorf("RequireChannelAccess with a missing permission = %v, want ErrPermissionDenied", err)
}
}
func TestRequireChannelAccess_DMParticipant(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 20, Name: "dm", Type: "dm"})
seedDMParticipant(t, database, 20, 1)
ctx := context.Background()
// For a DM the permission argument is irrelevant — membership decides.
// Pass a permission the Member role does not hold to prove that.
if err := svc.RequireChannelAccess(ctx, 1, "dm", 20, permissions.ManageChannels); err != nil {
t.Errorf("RequireChannelAccess for a DM participant = %v, want nil", err)
}
}
func TestRequireChannelAccess_DMNonParticipant(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 20, Name: "dm", Type: "dm"})
seedUserRole(t, database, 2, permissions.MemberRoleID)
seedDMParticipant(t, database, 20, 1)
ctx := context.Background()
// User 2 is not in the DM. Role permissions must not open it.
err := svc.RequireChannelAccess(ctx, 2, "dm", 20, permissions.ReadMessages)
if !errors.Is(err, permissions.ErrNotDMParticipant) {
t.Errorf("RequireChannelAccess for a non-participant = %v, want ErrNotDMParticipant", err)
}
}
func TestRequireChannelAccess_DMWithNoParticipantsDenies(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 21, Name: "empty-dm", Type: "dm"})
ctx := context.Background()
err := svc.RequireChannelAccess(ctx, 1, "dm", 21, permissions.ReadMessages)
if !errors.Is(err, permissions.ErrNotDMParticipant) {
t.Errorf("RequireChannelAccess on a DM with no participants = %v, want ErrNotDMParticipant", err)
}
}
func TestRequireChannelAccess_UnknownUserDenied(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
ctx := context.Background()
// A user with no row has no role, so getOrPopulate yields nothing and the
// check must fail closed.
err := svc.RequireChannelAccess(ctx, 9999, "text", 10, permissions.ReadMessages)
if !errors.Is(err, permissions.ErrPermissionDenied) {
t.Errorf("RequireChannelAccess for an unknown user = %v, want ErrPermissionDenied", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package telemetry_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/owncord/server/telemetry"
)
// The no-op provider is what runs in every build without -tags otel — i.e. the
// default binary and all of CI. Its methods had no coverage, so nothing caught
// a nil-pointer or panic on the path every instrumented call takes in the
// shipped build.
func TestNoopProvider_TracerAndSpanAreInert(t *testing.T) {
tracer := telemetry.GlobalTracer("test")
if tracer == nil {
t.Fatal("GlobalTracer returned nil")
}
ctx, span := tracer.Start(context.Background(), "op",
telemetry.String("s", "v"),
telemetry.Int64("i", 7),
telemetry.Float64("f", 1.5),
)
if ctx == nil {
t.Fatal("Start returned a nil context")
}
if span == nil {
t.Fatal("Start returned a nil span")
}
// Every span method must be safe to call on the no-op implementation;
// service code calls these unconditionally.
span.SetAttributes(telemetry.String("k", "v"))
span.RecordError(errors.New("boom"))
span.End()
}
func TestNoopProvider_MeterInstrumentsAreInert(t *testing.T) {
meter := telemetry.GlobalMeter("test")
if meter == nil {
t.Fatal("GlobalMeter returned nil")
}
ctx := context.Background()
counter := meter.Counter("c", "count of things")
if counter == nil {
t.Fatal("Counter returned nil")
}
counter.Add(ctx, 1, telemetry.String("k", "v"))
hist := meter.Histogram("h", "s", "durations")
if hist == nil {
t.Fatal("Histogram returned nil")
}
hist.Record(ctx, 0.25, telemetry.String("k", "v"))
gauge := meter.Gauge("g", "a level")
if gauge == nil {
t.Fatal("Gauge returned nil")
}
gauge.Set(ctx, 3, telemetry.String("k", "v"))
}
func TestAttrConstructors(t *testing.T) {
tests := []struct {
name string
got telemetry.Attr
key string
val any
}{
{"String", telemetry.String("s", "v"), "s", "v"},
{"Int64", telemetry.Int64("i", 7), "i", int64(7)},
{"Float64", telemetry.Float64("f", 1.5), "f", 1.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got.Key != tt.key {
t.Errorf("Key = %q, want %q", tt.got.Key, tt.key)
}
if tt.got.Value != tt.val {
t.Errorf("Value = %#v, want %#v", tt.got.Value, tt.val)
}
})
}
}
func TestTimeSince(t *testing.T) {
// TimeSince is called from deferred blocks all over the service layer with
// whatever the metrics struct holds — including nil in the no-op build.
telemetry.TimeSince(context.Background(), nil, time.Now())
hist := telemetry.GlobalMeter("test").Histogram("h", "s", "d")
telemetry.TimeSince(context.Background(), hist, time.Now().Add(-time.Second),
telemetry.String("method", "Test"))
}
func TestNoopProvider_HTTPMiddlewareIsPassThrough(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
})
wrapped := telemetry.Global().HTTPMiddleware(next)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if !called {
t.Error("the wrapped handler was not invoked")
}
if rr.Code != http.StatusTeapot {
t.Errorf("status = %d, want the inner handler's 418", rr.Code)
}
}
func TestNewAppMetrics_InstrumentsAreUsable(t *testing.T) {
m := telemetry.NewAppMetrics()
if m == nil {
t.Fatal("NewAppMetrics returned nil")
}
// ServiceCallDurationSec is passed straight into TimeSince by the service
// layer, so it must never be nil.
if m.ServiceCallDurationSec == nil {
t.Error("ServiceCallDurationSec is nil")
}
telemetry.TimeSince(context.Background(), m.ServiceCallDurationSec, time.Now())
}
+19
View File
@@ -310,6 +310,25 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64,
h.handleWebhookParticipantLeft(context.Background(), event)
}
// HandleWebhookParticipantJoinedForTest exposes handleWebhookParticipantJoined
// 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) {
event := &livekit.WebhookEvent{
Event: "participant_joined",
Participant: &livekit.ParticipantInfo{Identity: identity},
Room: &livekit.Room{Name: roomName},
}
h.handleWebhookParticipantJoined(context.Background(), event)
}
// HandleWebhookParticipantJoinedEventForTest exposes
// handleWebhookParticipantJoined with a caller-built event so tests can cover
// the nil-participant and nil-room guards.
func (h *Hub) HandleWebhookParticipantJoinedEventForTest(event *livekit.WebhookEvent) {
h.handleWebhookParticipantJoined(context.Background(), event)
}
// MustFullResyncForTest exposes mustFullResync for external tests.
func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool {
return h.mustFullResync(lastSeq)
+151
View File
@@ -0,0 +1,151 @@
package ws_test
import (
"encoding/json"
"testing"
"time"
"github.com/owncord/server/ws"
)
// BroadcastUserUpdate, BroadcastDropCount and SetEventPersister had no
// coverage. The first is what propagates a profile or identity-key change to
// 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 {
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
case <-time.After(2 * time.Second):
t.Fatal("no message received")
return nil
}
}
func TestHub_BroadcastUserUpdate(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
client := ws.NewTestClient(hub, 1, send)
hub.RegisterNowForTest(client)
avatar := "avatar.png"
identityKey := "pubkey-abc"
hub.BroadcastUserUpdate(42, "renamed", &avatar, &identityKey)
msg := awaitMessage(t, send)
if msg["type"] != "user_update" {
t.Fatalf("type = %v, want user_update", msg["type"])
}
payload, ok := msg["payload"].(map[string]any)
if !ok {
t.Fatalf("payload is not an object: %v", msg["payload"])
}
if payload["user_id"] != float64(42) {
t.Errorf("user_id = %v, want 42", payload["user_id"])
}
if payload["username"] != "renamed" {
t.Errorf("username = %v, want renamed", payload["username"])
}
if payload["avatar"] != "avatar.png" {
t.Errorf("avatar = %v, want avatar.png", payload["avatar"])
}
// The identity key is the E2EE handshake input; dropping it here would
// leave peers unable to derive a session with this user.
if payload["identity_public_key"] != "pubkey-abc" {
t.Errorf("identity_public_key = %v, want pubkey-abc", payload["identity_public_key"])
}
}
func TestHub_BroadcastUserUpdate_NilOptionalFields(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send))
hub.BroadcastUserUpdate(42, "noextras", nil, nil)
msg := awaitMessage(t, send)
payload, ok := msg["payload"].(map[string]any)
if !ok {
t.Fatalf("payload is not an object: %v", msg["payload"])
}
if payload["username"] != "noextras" {
t.Errorf("username = %v, want noextras", payload["username"])
}
// A user with no avatar / no published key must serialize as null rather
// than an empty string, so clients can tell "unset" from "cleared".
if v, present := payload["avatar"]; present && v != nil {
t.Errorf("avatar = %v, want null", v)
}
if v, present := payload["identity_public_key"]; present && v != nil {
t.Errorf("identity_public_key = %v, want null", v)
}
}
func TestHub_BroadcastUserUpdate_ReachesEveryClient(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
a := make(chan []byte, 8)
b := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, a))
hub.RegisterNowForTest(ws.NewTestClient(hub, 2, b))
hub.BroadcastUserUpdate(7, "everyone", nil, nil)
for i, ch := range []chan []byte{a, b} {
msg := awaitMessage(t, ch)
if msg["type"] != "user_update" {
t.Errorf("client %d got type %v, want user_update", i, msg["type"])
}
}
}
func TestHub_BroadcastDropCount(t *testing.T) {
hub, _ := newTestHub(t)
// A hub that has broadcast nothing has dropped nothing. The admin
// diagnostics endpoint reads this counter, so a nonzero baseline would
// read as backpressure that never happened.
if got := hub.BroadcastDropCount(); got != 0 {
t.Errorf("BroadcastDropCount = %d on a fresh hub, want 0", got)
}
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send))
hub.BroadcastUserUpdate(1, "u", nil, nil)
awaitMessage(t, send)
// A single delivered broadcast must not increment the drop counter.
if got := hub.BroadcastDropCount(); got != 0 {
t.Errorf("BroadcastDropCount = %d after one delivered broadcast, want 0", got)
}
}
func TestHub_SetEventPersister(t *testing.T) {
hub, database := newTestHub(t)
persister := ws.NewEventPersister(database, 16, 4, 10*time.Millisecond)
// 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)
}
+220
View File
@@ -0,0 +1,220 @@
package ws_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/livekit/protocol/livekit"
"google.golang.org/protobuf/proto"
"github.com/owncord/server/config"
"github.com/owncord/server/ws"
)
// LiveKitClient.ListParticipants, CountVideoTracks and HealthCheck had no
// coverage — CountVideoTracks in particular gates MaxVideo enforcement, so a
// miscount silently changes who is allowed to turn a camera on.
//
// The room service client speaks Twirp over HTTP, so these tests stand up an
// httptest server that replies with real protobuf-encoded responses.
// twirpServer returns an httptest server that answers every Twirp RPC with the
// supplied protobuf message, and a client pointed at it.
func twirpServer(t *testing.T, status int, reply proto.Message) *ws.LiveKitClient {
t.Helper()
var body []byte
if reply != nil {
var err error
body, err = proto.Marshal(reply)
if err != nil {
t.Fatalf("marshal reply: %v", err)
}
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if status != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"code":"internal","msg":"boom"}`))
return
}
w.Header().Set("Content-Type", "application/protobuf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
client, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "testkeytestkeytest",
LiveKitAPISecret: "testsecrettestsecrettestsecret",
LiveKitURL: "ws://" + srv.Listener.Addr().String(),
})
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
return client
}
func TestLiveKitClient_ListParticipants_Empty(t *testing.T) {
client := twirpServer(t, http.StatusOK, &livekit.ListParticipantsResponse{})
got, err := client.ListParticipants(42)
if err != nil {
t.Fatalf("ListParticipants: %v", err)
}
if len(got) != 0 {
t.Errorf("ListParticipants = %v, want empty", got)
}
}
func TestLiveKitClient_ListParticipants_ReturnsParticipants(t *testing.T) {
client := twirpServer(t, http.StatusOK, &livekit.ListParticipantsResponse{
Participants: []*livekit.ParticipantInfo{
{Identity: "user-1:tok"},
{Identity: "user-2:tok"},
},
})
got, err := client.ListParticipants(42)
if err != nil {
t.Fatalf("ListParticipants: %v", err)
}
if len(got) != 2 {
t.Fatalf("ListParticipants returned %d participants, want 2", len(got))
}
if got[0].Identity != "user-1:tok" {
t.Errorf("participant[0].Identity = %q, want %q", got[0].Identity, "user-1:tok")
}
}
func TestLiveKitClient_ListParticipants_ServerError(t *testing.T) {
client := twirpServer(t, http.StatusInternalServerError, nil)
if _, err := client.ListParticipants(42); err == nil {
t.Error("ListParticipants against a failing server returned nil error")
}
}
func TestLiveKitClient_CountVideoTracks(t *testing.T) {
tests := []struct {
name string
participants []*livekit.ParticipantInfo
want int
}{
{
name: "no participants",
want: 0,
},
{
name: "audio only",
participants: []*livekit.ParticipantInfo{
{Tracks: []*livekit.TrackInfo{{Type: livekit.TrackType_AUDIO}}},
},
want: 0,
},
{
name: "one video among audio",
participants: []*livekit.ParticipantInfo{
{Tracks: []*livekit.TrackInfo{
{Type: livekit.TrackType_AUDIO},
{Type: livekit.TrackType_VIDEO},
}},
},
want: 1,
},
{
name: "video counted across participants",
participants: []*livekit.ParticipantInfo{
{Tracks: []*livekit.TrackInfo{{Type: livekit.TrackType_VIDEO}}},
{Tracks: []*livekit.TrackInfo{
{Type: livekit.TrackType_VIDEO},
{Type: livekit.TrackType_VIDEO},
}},
{Tracks: []*livekit.TrackInfo{{Type: livekit.TrackType_AUDIO}}},
},
want: 3,
},
{
name: "participant with no tracks",
participants: []*livekit.ParticipantInfo{
{Identity: "user-1"},
{Tracks: []*livekit.TrackInfo{{Type: livekit.TrackType_VIDEO}}},
},
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := twirpServer(t, http.StatusOK, &livekit.ListParticipantsResponse{
Participants: tt.participants,
})
got, err := client.CountVideoTracks(7)
if err != nil {
t.Fatalf("CountVideoTracks: %v", err)
}
if got != tt.want {
t.Errorf("CountVideoTracks = %d, want %d", got, tt.want)
}
})
}
}
func TestLiveKitClient_CountVideoTracks_PropagatesError(t *testing.T) {
client := twirpServer(t, http.StatusInternalServerError, nil)
got, err := client.CountVideoTracks(7)
if err == nil {
t.Fatal("CountVideoTracks against a failing server returned nil error")
}
if got != 0 {
t.Errorf("count = %d on error, want 0", got)
}
}
func TestLiveKitClient_HealthCheck_Success(t *testing.T) {
client := twirpServer(t, http.StatusOK, &livekit.ListRoomsResponse{})
ok, err := client.HealthCheck(context.Background())
if err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if !ok {
t.Error("HealthCheck = false against a healthy server")
}
}
func TestLiveKitClient_HealthCheck_ServerError(t *testing.T) {
client := twirpServer(t, http.StatusInternalServerError, nil)
ok, err := client.HealthCheck(context.Background())
if err == nil {
t.Fatal("HealthCheck against a failing server returned nil error")
}
if ok {
t.Error("HealthCheck = true despite an error")
}
}
func TestLiveKitClient_HealthCheck_Unreachable(t *testing.T) {
client, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "testkeytestkeytest",
LiveKitAPISecret: "testsecrettestsecrettestsecret",
LiveKitURL: "ws://127.0.0.1:1",
})
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
ok, err := client.HealthCheck(context.Background())
if err == nil {
t.Fatal("HealthCheck against an unreachable server returned nil error")
}
if ok {
t.Error("HealthCheck = true against an unreachable server")
}
}
+196
View File
@@ -0,0 +1,196 @@
package ws_test
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"testing"
"github.com/livekit/protocol/livekit"
"github.com/owncord/server/ws"
)
// handleWebhookParticipantJoined had no coverage. It is the server's guard
// against a replayed LiveKit join token: LiveKit reports who joined a room, and
// the hub cross-checks that against its own voice_states row, evicting anyone
// who has no matching state or presents a stale token. If that check silently
// stops firing, a leaked token grants voice access to a channel the holder was
// removed from.
//
// The handler's only side effects are a slog warning and a RemoveParticipant
// call, so these tests assert on captured log output.
// captureLogs swaps the default slog logger for one writing into a buffer and
// returns an accessor for what was written.
func captureLogs(t *testing.T) func() string {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(prev) })
return buf.String
}
func TestWebhook_ParticipantJoined_RogueParticipantFlagged(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "joined-rogue-user")
chanID := seedVoiceChan(t, database, "joined-rogue-ch")
logs := captureLogs(t)
// No voice_states row exists for this user — the join is unauthorized.
hub.HandleWebhookParticipantJoinedForTest(
participantIdentityFor(user.ID, "sometoken"),
roomNameFor(chanID),
)
if !strings.Contains(logs(), "rogue participant_joined") {
t.Errorf("no rogue-participant warning logged; got:\n%s", logs())
}
}
func TestWebhook_ParticipantJoined_StaleTokenFlagged(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "joined-stale-user")
chanID := seedVoiceChan(t, database, "joined-stale-ch")
if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
logs := captureLogs(t)
// A matching row exists, but the webhook presents a token from an older
// session. This is exactly the replay case the check exists for.
hub.HandleWebhookParticipantJoinedForTest(
participantIdentityFor(user.ID, "an-old-token"),
roomNameFor(chanID),
)
if !strings.Contains(logs(), "stale join token") {
t.Errorf("no stale-token warning logged; got:\n%s", logs())
}
}
func TestWebhook_ParticipantJoined_ValidJoinAccepted(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "joined-valid-user")
chanID := seedVoiceChan(t, database, "joined-valid-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)
}
logs := captureLogs(t)
hub.HandleWebhookParticipantJoinedForTest(
participantIdentityFor(user.ID, state.JoinedAt),
roomNameFor(chanID),
)
out := logs()
if strings.Contains(out, "rogue participant_joined") || strings.Contains(out, "stale join token") {
t.Errorf("a legitimate join was flagged; log:\n%s", out)
}
if !strings.Contains(out, "participant joined") {
t.Errorf("legitimate join was not logged at all; log:\n%s", out)
}
}
func TestWebhook_ParticipantJoined_WrongChannelFlagged(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "joined-wrongch-user")
joined := seedVoiceChan(t, database, "joined-wrongch-a")
other := seedVoiceChan(t, database, "joined-wrongch-b")
if err := database.JoinVoiceChannel(context.Background(), user.ID, joined); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
state, err := database.GetVoiceState(context.Background(), user.ID)
if err != nil || state == nil {
t.Fatalf("GetVoiceState: %v", err)
}
logs := captureLogs(t)
// Correct token, wrong room — the state's channel must match too.
hub.HandleWebhookParticipantJoinedForTest(
participantIdentityFor(user.ID, state.JoinedAt),
roomNameFor(other),
)
if !strings.Contains(logs(), "rogue participant_joined") {
t.Errorf("a join into a channel the user is not in was not flagged; got:\n%s", logs())
}
}
func TestWebhook_ParticipantJoined_MalformedInput(t *testing.T) {
tests := []struct {
name string
identity string
room string
wantLog string
}{
{"identity without user- prefix", "bogus", "channel-1", "bad identity"},
{"identity with non-numeric id", "user-abc:tok", "channel-1", "bad identity"},
{"empty identity", "", "channel-1", "bad identity"},
{"room without channel- prefix", "user-1:tok", "lobby", "bad room"},
{"room with non-numeric id", "user-1:tok", "channel-xyz", "bad room"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hub, _ := newVoiceHub(t)
logs := captureLogs(t)
// A webhook body is attacker-influenced input; the handler must
// reject malformed values rather than panic or act on them.
hub.HandleWebhookParticipantJoinedForTest(tt.identity, tt.room)
if !strings.Contains(logs(), tt.wantLog) {
t.Errorf("log does not mention %q; got:\n%s", tt.wantLog, logs())
}
})
}
}
func TestWebhook_ParticipantJoined_NilFieldsIgnored(t *testing.T) {
hub, _ := newVoiceHub(t)
logs := captureLogs(t)
// GetParticipant/GetRoom return nil for a partial event; the guard must
// bail out before dereferencing either.
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{Event: "participant_joined"})
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{
Event: "participant_joined",
Room: &livekit.Room{Name: "channel-1"},
})
hub.HandleWebhookParticipantJoinedEventForTest(&livekit.WebhookEvent{
Event: "participant_joined",
Participant: &livekit.ParticipantInfo{Identity: "user-1:tok"},
})
if out := logs(); strings.Contains(out, "participant joined") {
t.Errorf("an event with nil participant/room was processed; log:\n%s", out)
}
}
// participantIdentityFor mirrors the identity format LiveKit sends back.
func participantIdentityFor(userID int64, joinToken string) string {
id := "user-" + strconv.FormatInt(userID, 10)
if joinToken == "" {
return id
}
return id + ":" + joinToken
}
func roomNameFor(channelID int64) string {
return ws.RoomName(channelID)
}
+96
View File
@@ -0,0 +1,96 @@
package ws
import (
"testing"
"time"
)
// TopicRateLimiter.Cleanup is the only thing bounding the bucket map: a busy
// server sees a bucket per topic, and topics include per-channel and per-DM
// values, so without the sweep the map grows for the life of the process.
// It had no coverage. This test lives in package ws so it can read the
// unexported bucket map directly rather than inferring size from behaviour.
func (trl *TopicRateLimiter) bucketCount() int {
trl.mu.Lock()
defer trl.mu.Unlock()
return len(trl.buckets)
}
func TestTopicRateLimiter_Cleanup_RemovesStaleBuckets(t *testing.T) {
trl := NewTopicRateLimiter(10, time.Second)
trl.Allow(Topic("channel:1"))
trl.Allow(Topic("channel:2"))
if got := trl.bucketCount(); got != 2 {
t.Fatalf("bucketCount = %d after two topics, want 2", got)
}
// Backdate one bucket so it falls outside the max age.
trl.mu.Lock()
trl.buckets[Topic("channel:1")].lastReset = time.Now().Add(-time.Hour)
trl.mu.Unlock()
trl.Cleanup(30 * time.Minute)
if got := trl.bucketCount(); got != 1 {
t.Fatalf("bucketCount = %d after cleanup, want 1", got)
}
trl.mu.Lock()
_, staleSurvives := trl.buckets[Topic("channel:1")]
_, freshSurvives := trl.buckets[Topic("channel:2")]
trl.mu.Unlock()
if staleSurvives {
t.Error("the stale bucket survived Cleanup")
}
if !freshSurvives {
t.Error("Cleanup removed a bucket that was still within maxAge")
}
}
func TestTopicRateLimiter_Cleanup_KeepsFreshBuckets(t *testing.T) {
trl := NewTopicRateLimiter(10, time.Second)
trl.Allow(Topic("channel:1"))
trl.Cleanup(time.Hour)
if got := trl.bucketCount(); got != 1 {
t.Errorf("bucketCount = %d, want the fresh bucket to survive", got)
}
}
func TestTopicRateLimiter_Cleanup_EmptyMap(t *testing.T) {
trl := NewTopicRateLimiter(10, time.Second)
trl.Cleanup(time.Minute) // must not panic on an empty map
if got := trl.bucketCount(); got != 0 {
t.Errorf("bucketCount = %d, want 0", got)
}
}
func TestTopicRateLimiter_Allow_EnforcesQuotaThenRefills(t *testing.T) {
trl := NewTopicRateLimiter(2, 50*time.Millisecond)
topic := Topic("channel:1")
if !trl.Allow(topic) || !trl.Allow(topic) {
t.Fatal("the first two messages were rejected despite a quota of 2")
}
if trl.Allow(topic) {
t.Error("a third message was allowed within the same window")
}
// A separate topic has its own bucket — one busy channel must not starve
// the others, which is the whole point of the per-topic limiter.
if !trl.Allow(Topic("channel:2")) {
t.Error("a different topic was rate limited by channel:1's usage")
}
// After the window elapses the bucket refills.
trl.mu.Lock()
trl.buckets[topic].lastReset = time.Now().Add(-time.Second)
trl.mu.Unlock()
if !trl.Allow(topic) {
t.Error("the bucket did not refill after its window elapsed")
}
}
+156
View File
@@ -0,0 +1,156 @@
# OwnCord — Test-Coverage Audit
**Date:** 2026-07-25
**Branch:** claude/test-coverage-audit-tvp07j (audited tree: `70caa6c` = main)
**Scope:** "does everything that should have a test have a test?" — measured, not estimated, across all three surfaces (Go server, TypeScript client, Rust Tauri backend), plus the CI configuration that decides which of those tests actually run.
**Relationship to prior audits:** narrower and newer than [audit-2026-07-19.md](audit-2026-07-19.md), which remains the closure tracker for architectural findings. Prior test-related items (#6 `store/` untested, #7 client unit coverage, A-2026-07-04 client suite red) are closed there and are not restated.
Unlike the prior audits, this one shipped its fixes: the findings below are recorded together with the change that closed them.
---
## 1. How coverage was measured (and why the CI number is wrong)
`go test ./... -coverprofile` — the invocation in `.github/workflows/ci.yml` — instruments
each package **only for itself**. A package whose code is mostly exercised through another
package's tests reports far below its real coverage. Concretely, `service` reported 36.7%
while its true cross-package exercise was 85%.
Everything in this document therefore uses `-coverpkg=./...`. Both numbers are now one
command away — see `make cover` and `make cover-all` in `Server/Makefile` (§4).
The client and Rust numbers come from `vitest run --coverage` and a per-file census of
`#[cfg(test)]` modules respectively; there is no Rust coverage instrumentation in the repo
(§5, standing gap).
---
## 2. Finding closure status
| ID | Sev | Finding | Status |
|----|-----|---------|--------|
| T-2026-07-25-01 | HIGH | `Server/admin` reported **0.3% coverage despite 307 passing tests**, and `go test` printed "[no tests to run]" for it. `TestSpawnDetached_*` re-execs the test binary; the child inherited `GOCOVERDIR` and the parent's stdout, so it clobbered the coverage profile and polluted the result stream. CI's uploaded `coverage.out` artifact was wrong for this package | **RESOLVED**`Server/admin/middleware_and_spawn_test.go` now points the child's counters at `t.TempDir()` and uses `-test.list` (silent exit) instead of `-test.run`. Package reports **71.4%** |
| T-2026-07-25-02 | HIGH | User blocking had **zero coverage at every layer**`db.BlockUser`/`UnblockUser`/`IsBlocked`/`ListBlockedUsers`, all of `service/block.go`, and the `PUT`/`DELETE`/`GET /api/v1/blocks` routes. A whole user-facing feature, including the DM-authorization predicate `IsEitherBlocked` | **RESOLVED**`Server/db/block_queries_test.go`, `Server/service/block_test.go`, `Server/api/blocks_handler_test.go` |
| T-2026-07-25-03 | HIGH | Auth lockout **persistence** untested (`UpsertLockout`, `CleanupExpiredLockouts`, `DeleteLockout`). `auth/ratelimit_test.go` covers the in-memory limiter but not the DB round-trip that makes a brute-force lockout survive a restart | **RESOLVED**`Server/db/lockout_queries_test.go` |
| T-2026-07-25-04 | HIGH | Rust: `ws_proxy.rs` (340 LOC) and `livekit_proxy.rs` (332 LOC) had **no tests at all** — the two proxies carrying every byte of app traffic, including TOFU cert pinning and proxy header rewriting | **RESOLVED** — pure helpers extracted (matching the existing `tofu.rs` pattern) and tested: cert-fingerprint validation, `remote_host` CRLF/charset validation, `Host`/`Origin` rewriting, TLS server-name parsing |
| T-2026-07-25-05 | HIGH | Rust unit tests ran **only on PRs to `main`**, inside the expensive `tauri-build` job. Pushes and PRs to `dev` never compiled `#[cfg(test)]` code, so it could rot for a full release cycle | **RESOLVED** — standalone `rust-tests` job in `ci.yml`, runs on every event, with `cargo clippy --all-targets` (the existing lib-only clippy skips test code) |
| T-2026-07-25-06 | HIGH | **40 Playwright spec files had never run in CI.** No e2e job existed in any workflow | **RESOLVED (partial)** — new `client-e2e` job runs the mocked-Tauri config, `continue-on-error: true` for a soak period per backlog #10. The native config still is not wired (needs a real server + built binary) |
| T-2026-07-25-07 | MEDIUM | `vitest.config.ts` excluded **2,229 LOC** from coverage with no stated reason — including `window-state.ts` and `UpdateNotifier.ts`, which *already had passing tests*. Coverage for those never appeared in any report | **RESOLVED** — exclude list cut to three entries, each justified inline. `credentials.ts` and `updater.ts` gained tests and were un-excluded |
| T-2026-07-25-08 | MEDIUM | Plugin install/enable/disable/uninstall lifecycle and the entire plugin KV store untested. `plugin/registry.go` (558 LOC) was the largest untested source file in the repo; the KV namespace is the isolation boundary between plugins | **RESOLVED**`Server/plugin/registry_test.go`, `Server/db/plugin_queries_test.go` (including a namespace-isolation test and cascade-on-uninstall) |
| T-2026-07-25-09 | MEDIUM | `handleWebhookParticipantJoined` untested — the guard that evicts a LiveKit participant presenting a replayed or unmatched join token. Untrusted-input entry point | **RESOLVED**`Server/ws/livekit_webhook_joined_test.go` |
| T-2026-07-25-10 | MEDIUM | `proxyWebSocket` / `copyWS` untested: the existing `livekit_proxy_test.go` stopped at the path allowlist and Origin check, before the upgrade. Every LiveKit signalling frame flows through the untested half | **RESOLVED**`Server/api/livekit_proxy_ws_test.go` (real backend WS server, round-trip, 502 on backend failure, blocked-path and cross-origin upgrades) |
| T-2026-07-25-11 | MEDIUM | `api.HandleLiveKitHealthForTest` **re-implemented** `handleLiveKitHealth` instead of calling it. Eight test call sites asserted against a copy, so the production handler had 0% coverage and the two could drift silently | **RESOLVED (partial)** — added `LiveKitHealthHandlerForTest`, which returns the real handler, plus tests through it. The old hook is retained with a comment marking it as a duplicate; migrating its eight callers is follow-up work |
| T-2026-07-25-12 | MEDIUM | Client: six modules well under the 70% threshold with no test file of their own — `livekitDiagnostics` 30.4%, `drag-reorder` 38.8%, `deep-link` 44.1%, `roomEventHandlers` 57.1%, `screenShare` 61.1%, `volume-menu` 77.7% | **RESOLVED** — eight new test files; all now ≥96% except `screenShare`, whose remaining gap is the untested-by-design capture paths |
| T-2026-07-25-13 | MEDIUM | Event replay/retention partly untested (`GetMaxEventSeq` seeds the hub's sequence counter at startup; `PruneEventsOlderThan` is the retention job) | **RESOLVED**`Server/db/event_queries_test.go`, including the channel filter that stops a replay leaking events for channels a client cannot see |
| T-2026-07-25-14 | MEDIUM | Admin live-log stream: 11 consecutive uncovered functions in the `multiHandler` slog fan-out, including `Subscribe` — what a connected admin's SSE session hangs off | **RESOLVED**`Server/admin/multihandler_test.go` |
| T-2026-07-25-15 | MEDIUM | `Server/Makefile` had **no test target at all**, so there was no blessed way to reproduce the CI run or read coverage locally | **RESOLVED**`test`, `test-deadlock`, `cover`, `cover-all` added; `cover-all` prints the zero-coverage function list |
| T-2026-07-25-16 | MEDIUM | `-tags wazero` and `-tags otel` are only ever **built** in CI, never tested. ~598 lines of already-written tests (`plugin/sandbox_wazero_test.go`, `telemetry/telemetry_otel_test.go`) never execute, and the real WASM sandbox is untested in the default build | **OPEN — accepted for now.** Out of scope for this pass by explicit scoping decision. Single highest-leverage remaining CI change: add `go test -tags wazero ./plugin/...` and `go test -tags otel ./telemetry/...` |
| T-2026-07-25-17 | LOW | `Server/main.go` (452 LOC, `package main`) and `Server/scripts/seed.go` (371 LOC dev tool) have no tests | **OPEN.** `main.go` is wiring with no seam below the integration level; `seed.go` is a developer tool. Both are low-risk, but `main.go` is the largest untested single file on the server |
| T-2026-07-25-18 | LOW | `src/pages/MainPage.ts` (561 LOC orchestrator) and `src/main.ts` (597 LOC bootstrap) remain excluded from client coverage | **OPEN (documented).** Both exclusions now carry a written justification; `MainPage.ts` is explicitly tracked for unit tests, `main.ts` is bootstrap covered by e2e |
| T-2026-07-25-19 | LOW | No coverage threshold or ratchet on the Go side; no coverage instrumentation for Rust at all. The client's 70% vitest threshold is the only enforced floor anywhere | **OPEN.** Deliberately not added — a floor set below current coverage (8492% per package) is theatre, and a ratchet needs a baseline store this repo does not have |
| T-2026-07-25-20 | LOW | `Server/ws` failed twice under full-suite `-coverpkg` runs, but passed 5/5 in isolation and under `-race`, and the failing test name was not captured | **OPEN — watch.** Load-sensitive and unreproduced. Not present in the `-race` gate CI actually runs |
---
## 3. Measured baselines (diff against these next time)
### Go — cross-package (`make cover-all`)
| Package | Before | After | | Package | Before | After |
|---|---|---|---|---|---|---|
| `permissions` | 100% | 100% | | `db` | 76% | **84%** |
| `logctx` | 76% | **96%** | | `api` | 79% | **84%** |
| `stackutil` | 94% | 94% | | `plugin` | 61% | **77%** |
| `ws` | 90% | **92%** | | `telemetry` | 70% | **75%** |
| `service` | 85% | **91%** | | `admin` | 67% | **86%** |
| `config` | 91% | 91% | | `updater` | 86% | 86% |
| `auth` | 90% | 90% | | `storage` | 89% | 89% |
Package-local (the CI artifact view), after: `permissions` 100%, `stackutil` 94%, `logctx` 88.9%,
`storage` 86.5%, `config` 85.7%, `telemetry` 85.1%, `ws` 82.2%, `api` 79.6%, `db` 78.6%,
`admin` 77.9%, `auth` 77.5%, `updater` 76.7%, `plugin` 67.5%, `service` 41.9%.
The `service` figure is the measurement artifact described in §1, not a real gap.
**Functions with zero coverage** (excluding generated `db/dbgen`, `scripts/`, `main.go`):
**~70 → 21.** The remainder are `sandbox_default.go` build-tag stubs, no-op telemetry
shims, `updater.downloadWindowsBinaryAndVerify` (Windows-only), and trivial accessors.
### Client (`npx vitest run --coverage`)
| | Before | After |
|---|---|---|
| Test files | 121 | **129** |
| Tests | 3371 | **3572** |
| Statements | 92.93% | **94.87%** |
| Branches | 91.20% | **92.06%** |
| Functions | 93.51% | **94.28%** |
Statement coverage rose *despite* un-excluding previously hidden files. Per-module:
`livekitDiagnostics` 30.4→**100%**, `roomEventHandlers` 57.1→**100%**, `volume-menu`
77.7→**100%**, `credentials` excluded→**100%**, `updater` excluded→**100%**, `deep-link`
44.1→**96.6%**, `drag-reorder` 38.8→**96.5%**, `window-state` excluded→**95.7%**.
Modules that *looked* untested by filename but were already well covered indirectly —
recorded here so they are not re-flagged: `fenwick.ts` 95.9%, `formatting.ts` 100%,
`content-parser.ts`, `AccountTab.ts` 98.4%, `LoginForm.ts` 97.5%.
Still below 85% and untouched by this pass (pre-existing): `MessageList.ts` 83.5%,
`attachments.ts` 82.7%, `MemberList.ts` 81.4%, `livekitSession.ts` 79.4%,
`screenShare.ts` 61.1%, `UserProfilePopup.ts` 55.2% *branches*.
### Rust (`cargo test --lib`)
**47 → 74 tests.** Files with `#[cfg(test)]` modules: 6 of 12 → 8 of 12.
`tray.rs` (92 LOC), `lib.rs` (158 LOC), `main.rs` and `constants.rs` remain untested —
all plugin registration and menu wiring, accepted.
---
## 4. Two bugs surfaced by writing the tests
Both are pinned by tests describing actual behaviour, not silently patched.
**`Server/logctx``WithGroup` nests the correlation IDs.** `Handle` calls `r.AddAttrs`
after the inner handler has opened a group, so `req_id` is emitted as `http.req_id`.
A log search for a bare `req_id` stops matching. Harmless today because no production
code opens a logger-level group — which is exactly the condition the source comment says
to revisit. `TestHandlerSurvivesWithGroup` documents it.
**`drag-reorder.ts` — the listener ref-count never reaches zero.**
`attachDragHandlers` calls `ensureGlobalDragListeners()` once per *channel element*
(`ChannelSidebar.ts:431`, inside the per-channel render), while
`releaseGlobalDragListeners` is called once per *sidebar destroy* (`ChannelSidebar.ts:703`).
A sidebar with N channels takes N refs and returns 1, and every re-render takes N more.
The `AbortController` never fires, so both document listeners and the `activeDrag` closure
they capture live for the process lifetime. Currently benign — the handlers return
immediately while no drag is active — so the test pins the real behaviour under a
`KNOWN BUG` comment rather than changing sidebar lifecycle semantics.
---
## 5. CI gates after this pass
| Gate | Before | After |
|---|---|---|
| Go tests (`-race`, `-tags deadlock`) | every PR | unchanged |
| Go coverage artifact | uploaded, **wrong for `admin`** | uploaded, correct |
| Client unit tests + 70% threshold | every PR (blocking) | unchanged |
| Rust unit tests | **PRs to `main` only** | **every event** (`rust-tests`) |
| Rust clippy | lib only | lib (`tauri-build`) **+ `--all-targets`** (`rust-tests`) |
| Playwright e2e | **never** | **every PR, non-blocking** (`client-e2e`) |
| `-tags wazero` / `-tags otel` tests | never | **still never** (T-…-16) |
| Coverage floor / ratchet (Go, Rust) | none | none (T-…-19) |
---
## 6. Backlog
| # | Item | Finding | Sev |
|---|---|---|---|
| 1 | Run tag-gated tests in CI (`-tags wazero ./plugin/...`, `-tags otel ./telemetry/...`) — unlocks ~598 lines of existing tests | T-…-16 | MEDIUM |
| 2 | Promote `client-e2e` to blocking once it has soaked | T-…-06 | MEDIUM |
| 3 | Fix the `drag-reorder.ts` ref-count asymmetry and update its pinning test | §4 | MEDIUM |
| 4 | Migrate the eight `HandleLiveKitHealthForTest` callers to `LiveKitHealthHandlerForTest` and delete the duplicated hook | T-…-11 | LOW |
| 5 | Unit tests for `src/pages/MainPage.ts`, then remove its coverage exclusion | T-…-18 | LOW |
| 6 | Decide on `logctx.WithGroup` nesting before any logger-level group is introduced | §4 | LOW |
| 7 | Watch for the `ws` flake under instrumented full runs; capture the test name if it recurs | T-…-20 | LOW |
| 8 | `gofmt` misalignment in `Server/storage/storage.go` (pre-existing; CI runs golangci-lint, not `gofmt -l`) | — | LOW |