mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"]) == "[]"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 (84–92% 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 |
|
||||
Reference in New Issue
Block a user