diff --git a/.claude/skills/ci-check/SKILL.md b/.claude/skills/ci-check/SKILL.md new file mode 100644 index 00000000..72b6ed63 --- /dev/null +++ b/.claude/skills/ci-check/SKILL.md @@ -0,0 +1,65 @@ +--- +name: ci-check +description: Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not. +--- + +# ci-check + +`.github/workflows/ci.yml` is the source of truth. This mirrors it locally. + +Run only the sections your change touches. Server and client are independent. + +## Server (from `Server/`) + +All four build-tag variants must compile — the tags gate whole files, so a +default-build pass proves nothing about the others: + +```bash +go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./... +go vet ./... +go test -race ./... +go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies +golangci-lint run # CI pins v2.11.3 +make sqlc-verify protocol-verify # generated output must not be stale +``` + +Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`. + +A `windows-latest` `-race` failure inside `ws` that matches `runtime.scanstack` +or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change. +Rerun the job (`gh run rerun --job `); a job cannot be rerun while its +parent run is still in progress. + +## Client (from `Client/tauri-client/`) + +```bash +NODE_OPTIONS=--no-experimental-webstorage npm test +npm run typecheck +npm run lint +npm run format:check +``` + +The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md. + +`npm audit --audit-level=high` and `knip` also run in CI but are advisory. + +## Rust (from `Client/tauri-client/src-tauri/`) + +```bash +cargo test +cargo clippy --all-targets -- -D warnings +``` + +`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a +Windows box and only run on the Linux/macOS runners. + +Do not attempt `npm run tauri build` locally — the full desktop build runs in +CI on PRs to `main` and pulls heavy system dependencies. + +## Hooks + +`npm run hooks:install` (once per clone) points `core.hooksPath` at +`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the +server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds +server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still +enforces everything. diff --git a/.claude/skills/db-change/SKILL.md b/.claude/skills/db-change/SKILL.md new file mode 100644 index 00000000..8eb71819 --- /dev/null +++ b/.claude/skills/db-change/SKILL.md @@ -0,0 +1,44 @@ +--- +name: db-change +description: Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/. +--- + +# db-change + +`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both. + +1. Add the migration to `Server/migrations/` and/or edit + `Server/db/queries/sqlite/*.sql`. +2. Regenerate: `make sqlc-generate` from `Server/`. +3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs + `make sqlc-verify` and fails on drift. + +`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH: + +```bash +go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version) +$(go env GOPATH)/bin/sqlc generate +``` + +## Traps + +These are silent — the code generates fine and fails at runtime. + +**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions +against byte offsets, so one multi-byte character (an em-dash in a comment is +the usual culprit) truncates the *next* query's emitted SQL by that many +trailing bytes. Symptom: the `.sql` file looks right but the generated const +in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`, +and SQLite reports "incomplete input". + +**No semicolons inside migration `--` comments.** `splitStatements` in +`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon +in comment prose orphans the rest of that comment as a bogus statement +("near : syntax error"). + +**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`. +A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to +choose which one. + +After regenerating, gopls diagnostics against `dbgen` go stale. Trust +`go build`, not the editor squiggles. diff --git a/.claude/skills/protocol-change/SKILL.md b/.claude/skills/protocol-change/SKILL.md new file mode 100644 index 00000000..5db78b77 --- /dev/null +++ b/.claude/skills/protocol-change/SKILL.md @@ -0,0 +1,23 @@ +--- +name: protocol-change +description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts. +--- + +# protocol-change + +`docs/protocol-schema.json` is the source of truth. Both constant files are +generated from it by `Server/scripts/genprotocol/`. + +1. Edit `docs/protocol-schema.json`. +2. Run `make protocol-generate` from `Server/`. +3. Commit **both** outputs — `Server/ws/message_types.go` and + `Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the + pair; committing only the Go side is the usual mistake, and CI's + `make protocol-verify` fails on either being stale. + +Document the semantics in `docs/protocol.md` — the schema carries names and +shapes, not behaviour. + +Adding a message type is not enough to make it work: a server handler must be +registered in the `ws` V1/V2 dispatch tables, and the client needs a +`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`. diff --git a/.claude/workflows/bughunt.harness.mjs b/.claude/workflows/bughunt.harness.mjs new file mode 100644 index 00000000..787477e2 --- /dev/null +++ b/.claude/workflows/bughunt.harness.mjs @@ -0,0 +1,475 @@ +// Offline harness for bughunt.js - mimics the workflow runtime: wraps the script +// body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget. +// Run: node .claude/workflows/bughunt.harness.mjs [nameFilter] +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import assert from 'node:assert/strict' + +const here = dirname(fileURLToPath(import.meta.url)) +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor + +export async function run({ agentStub, args = undefined, budget = undefined }) { + const src = readFileSync(join(here, 'bughunt.js'), 'utf8') + const body = src.replace('export const meta', 'const meta') + const calls = [] + const logs = [] + const agent = async (prompt, opts = {}) => { + calls.push({ prompt, opts }) + return agentStub(prompt, opts) + } + const parallel = (thunks) => + Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null))) + const pipeline = (items, ...stages) => + Promise.all( + items.map(async (item, i) => { + let v = item + for (const stage of stages) { + try { + v = await stage(v, item, i) + } catch { + return null + } + } + return v + }), + ) + const log = (m) => logs.push(String(m)) + const phase = () => {} + const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity } + const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body) + const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl) + return { result, calls, logs } +} + +// ---------- stub kit (used from Task 2 onward; harmless now) ---------- +export function makeStub({ hunt, verify, report = () => 'REPORT_MD', recon = defaultRecon }) { + return (prompt, opts) => { + const label = opts.label || '' + if (label.startsWith('recon:')) return recon(label) + let m = /^r(\d+):hunt:([a-z0-9-]+):(opus|sonnet)$/.exec(label) + if (m) return hunt(Number(m[1]), m[2], m[3], prompt) + m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label) + if (m) { + const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1]) + return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt) + } + if (label === 'report') return report(prompt) + throw new Error(`unexpected agent label: ${label}`) + } +} +export function defaultRecon() { + return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8' +} +export const none = { findings: [] } +export const finding = (n, over = {}) => ({ + title: `distinct bug alpha${n} omega${n}`, + file: 'Server/ws/hub.go', + line: 100 + n * 40, + severity: 'high', + why: 'w', + repro: 'r', + evidence: 'e', + ...over, +}) +export const confirmAll = (cands) => ({ + verdicts: cands.map((c) => ({ + title: c.title, file: c.file, line: c.line, + refuted: false, reason: 'confirmed', confidence: 'high', + severity: c.severity || 'high', fix: 'fix', + })), +}) +export const refuteAll = (cands) => ({ + verdicts: cands.map((c) => ({ + title: c.title, file: c.file, line: c.line, + refuted: true, reason: 'refuted', confidence: 'high', + severity: c.severity || 'high', + })), +}) + +// ---------- scenarios ---------- +const scenarios = {} + +// S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged. +scenarios.s1_convergence = async () => { + const reportPrompts = [] + const { result, calls } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => + round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + verify: (round, key, cands) => confirmAll(cands), + report: (prompt) => { + reportPrompts.push(prompt) + return 'REPORT_MD' + }, + }), + }) + for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report']) + assert.ok(k in result, `missing key ${k}`) + assert.equal(result.converged, true) + assert.equal(result.rounds.length, 3) + assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2]) + assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows']) + assert.equal(result.confirmed.length, 1) + assert.equal(result.report, 'REPORT_MD') + assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence') + assert.match(reportPrompts[0], /CONVERGED after 3 round\(s\)/) + assert.match(reportPrompts[0], /\| 1 \| surfaces \|/) +} + +// S2: panel dedupe - opus and sonnet report the same bug -> one candidate, one verify call. +scenarios.s2_panel_dedupe = async () => { + const verifyBatches = [] + const { result } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => { + if (round !== 1 || key !== 'ws-hub') return none + return model === 'opus' + ? { findings: [finding(1, { line: 100 })] } + : { findings: [finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] } + }, + verify: (round, key, cands) => { + verifyBatches.push(cands) + return confirmAll(cands) + }, + }), + }) + assert.equal(verifyBatches.length, 1) + assert.equal(verifyBatches[0].length, 1) + assert.equal(result.confirmed.length, 1) +} + +// S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry. +scenarios.s3_refuted_permanence = async () => { + const { result, calls } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => { + if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] } + if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] } + return none + }, + verify: (round, key, cands) => refuteAll(cands), + }), + }) + const verifyRounds = calls + .map((c) => /^r(\d+):verify:/.exec(c.opts.label || '')) + .filter(Boolean) + .map((m) => Number(m[1])) + assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2') + assert.equal(result.rounds[0].refuted, 1) + assert.equal(result.confirmed.length, 0) + assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2 + assert.equal(result.converged, true) + assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report') + assert.match(result.report, /Converged/i) +} + +// S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged. +scenarios.s4_backstop = async () => { + const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' } + const { result } = await run({ + args: { maxRounds: 3 }, + agentStub: makeStub({ + hunt: (round, key, model) => + model === 'opus' && key === firstLens[round] + ? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] } + : none, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + assert.equal(result.rounds.length, 3) + assert.equal(result.converged, false) + assert.equal(result.stoppedOnBudget, false) + assert.equal(result.confirmed.length, 3) + assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0]) +} + +// S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets. +scenarios.s5_finder_failure_ineligible = async () => { + const { result } = await run({ + args: { maxRounds: 3 }, + agentStub: makeStub({ + hunt: (round, key, model) => { + if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] } + if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder + return none + }, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + assert.equal(result.rounds[1].dryEligible, false) + assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1]) + assert.equal(result.converged, false) +} + +// S6: failed verifier retried once, retry succeeds. +scenarios.s6_verifier_retry = async () => { + const { result, calls } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => + round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null), + }), + }) + assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry'))) + assert.equal(result.confirmed.length, 1) + assert.equal(result.converged, true) +} + +// S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible; +// re-reported later, verified then, and scrubbed from the unverified list. +scenarios.s6b_verifier_double_failure = async () => { + const { result } = await run({ + args: { maxRounds: 3 }, + agentStub: makeStub({ + hunt: (round, key, model) => { + if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] } + if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] } + return none + }, + verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)), + }), + }) + assert.equal(result.rounds[0].dryEligible, false) + assert.equal(result.rounds[0].confirmed, 0) + assert.equal(result.confirmed.length, 1) + assert.equal(result.confirmed[0].round, 2) + assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list') +} + +// S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses built from the stats. +scenarios.s7_adaptive_lenses = async () => { + const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' }) + const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' }) + const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' }) + const { result, calls } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => { + if (model !== 'opus') return none + if (round === 1 && key === 'ws-hub') return { findings: [A] } + if (round === 2 && key === 'concurrency') return { findings: [B] } + if (round === 3 && key === 'flow-voice') return { findings: [C] } + return none + }, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + assert.equal(result.converged, true) + assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry + assert.equal(result.rounds[3].family, 'adaptive') + const r4Hunts = calls.filter((c) => /^r4:hunt:/.test(c.opts.label || '')) + const r4Keys = [...new Set(r4Hunts.map((c) => c.opts.label.split(':')[2]))] + assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`) + assert.ok(r4Keys.includes('fresh-eyes'), `r4 keys: ${r4Keys}`) + const hotspot = r4Hunts.find((c) => c.opts.label.includes('hotspot-server-ws')) + assert.match(hotspot.prompt, /Server\/ws\/hub\.go/) + assert.match(hotspot.prompt, /alpha race window one/) + const freshEyes = r4Hunts.find((c) => c.opts.label.includes('fresh-eyes')) + assert.match(freshEyes.prompt, /Server\/api\/user\.go/) // churned, never a finding + assert.equal(result.confirmed.length, 3) +} + +// S7b: a lens with 2 consecutive clean rounds is demoted from later rounds. +scenarios.s7b_demotion = async () => { + const { result, calls } = await run({ + args: { maxRounds: 6 }, + agentStub: makeStub({ + hunt: (round, key, model) => { + if (model !== 'opus') return none + const src = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect', 4: 'hotspot-server-ws', 5: 'hotspot-server-ws' } + if (key === src[round]) + return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `unique bug number${round} zeta${round}` })] } + return none + }, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + const labels = calls.map((c) => c.opts.label || '') + assert.ok(labels.some((l) => /^r5:hunt:fresh-eyes:/.test(l)), 'fresh-eyes still runs in r5 (streak 1)') + assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'fresh-eyes demoted in r6 (streak 2)') + assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'producing hotspot keeps running') + assert.equal(result.converged, false) + assert.equal(result.confirmed.length, 5) +} + +// S7c: a lens whose VERIFIER died is not demoted; a zero-candidate lens still is. +scenarios.s7c_verifier_failure_not_demoted = async () => { + const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' } + const { result, calls } = await run({ + args: { maxRounds: 6 }, + agentStub: makeStub({ + hunt: (round, key, model) => { + if (model !== 'opus') return none + if (round <= 3 && key === early[round]) + return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] } + if (round >= 4 && key === 'hotspot-server-ws') + return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] } + return none + }, + verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null), + }), + }) + const labels = calls.map((c) => c.opts.label || '') + assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'verifier-dead lens must NOT be demoted') + assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'zero-candidate lens still accrues streak and demotes') + assert.equal(result.confirmed.length, 3) + assert.equal(result.unverified.length, 3) + assert.equal(result.converged, false) + assert.ok(result.rounds.slice(3).every((r) => r.dryEligible === false)) +} + +// S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds. +scenarios.s12_empty_adaptive_family = async () => { + const { result } = await run({ + agentStub: makeStub({ + recon: () => 'no parseable churn output', + hunt: (round, key, model) => { + if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus') + return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] } + return none + }, + verify: () => null, + }), + }) + assert.equal(result.rounds.length, 3) + assert.equal(result.converged, false) + assert.equal(result.confirmed.length, 0) + assert.equal(result.unverified.length, 2) +} + +// S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence. +scenarios.s8_budget_floor = async () => { + const { result, calls } = await run({ + budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 }, + agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), + }) + assert.equal(result.rounds.length, 0) + assert.equal(result.stoppedOnBudget, true) + assert.equal(result.converged, false) + assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || ''))) + assert.match(result.report, /budget/i) +} + +// S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next. +scenarios.s8b_budget_midrun = async () => { + let n = 0 + const { result } = await run({ + budget: { total: 1000000, spent: () => 0, remaining: () => (n++ === 0 ? 200000 : 100000) }, + agentStub: makeStub({ + hunt: (round, key, model) => + round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + assert.equal(result.rounds.length, 1) + assert.equal(result.stoppedOnBudget, true) + assert.equal(result.converged, false) + assert.equal(result.confirmed.length, 1) +} + +// S14: the title-word dedupe branch applies only near the prior's location. +// Dedupe is permanent, so merging two distinct same-file bugs that happen to +// share half their title words loses the second one forever. +scenarios.s14_title_dedupe_window = async () => { + const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' } + const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' } + const verifyBatches = [] + const { result } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => { + if (model !== 'opus') return none + if (round === 1 && key === 'ws-hub') + return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] } + if (round === 2 && key === 'concurrency') + return { findings: [finding(2, far), finding(3, near)] } + return none + }, + verify: (round, key, cands) => { + verifyBatches.push(cands.map((c) => c.line)) + return confirmAll(cands) + }, + }), + }) + assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept') + assert.equal(result.confirmed.length, 2) + assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe') +} + +// S13: JSON-stringified args must behave identically to object args (observed live: the +// runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion). +scenarios.s13_string_args = async () => { + const { result, calls } = await run({ + args: '{"maxRounds": 1}', + agentStub: makeStub({ + hunt: (round, key, model) => + round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + verify: (round, key, cands) => confirmAll(cands), + }), + }) + assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round') + assert.equal(result.converged, false) + assert.equal(result.confirmed.length, 1) + assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap') +} + +// S10: verifier returns truncated (empty) verdict lists on both attempts -> +// candidates land in unverified, round ineligible, dry counter untouched. +scenarios.s10_truncated_verdicts = async () => { + const { result, calls } = await run({ + args: { maxRounds: 2 }, + agentStub: makeStub({ + hunt: (round, key, model) => + round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + verify: () => ({ verdicts: [] }), + }), + }) + assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry') + assert.equal(result.rounds[0].dryEligible, false) + assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1]) + assert.equal(result.confirmed.length, 0) + assert.equal(result.unverified.length, 1) + assert.equal(result.converged, false) +} + +// S11: verdict coordinates drift from the candidate's -> still pairs, confirms once, +// nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped. +scenarios.s11_drifted_verdict = async () => { + const orig = finding(4) // file Server/ws/hub.go, line 260 + const { result, calls } = await run({ + agentStub: makeStub({ + hunt: (round, key, model) => { + if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] } + if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] } + return none + }, + verify: (round, key, cands) => ({ + verdicts: cands.map((c) => ({ + title: c.title, file: c.file, line: c.line + 5, + refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix', + })), + }), + }), + }) + const verifyRounds = calls + .map((c) => /^r(\d+):verify:/.exec(c.opts.label || '')) + .filter(Boolean) + .map((m) => Number(m[1])) + assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords') + assert.equal(result.confirmed.length, 1) + assert.equal(result.unverified.length, 0) + assert.equal(result.converged, true) +} + +// ---------- runner ---------- +const only = process.argv[2] +for (const [name, fn] of Object.entries(scenarios)) { + if (only && !name.includes(only)) continue + try { + await fn() + } catch (e) { + console.error(`FAIL ${name}`) + throw e + } + console.log(`PASS ${name}`) +} +console.log('all scenarios pass') diff --git a/.claude/workflows/bughunt.js b/.claude/workflows/bughunt.js new file mode 100644 index 00000000..6690f758 --- /dev/null +++ b/.claude/workflows/bughunt.js @@ -0,0 +1,565 @@ +export const meta = { + name: 'bughunt', + description: 'Converging multi-round bug hunt: rotating lens families, dual-model panels, fable refute-by-default verification, dry-threshold stop', + whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.', + phases: [ + { title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' }, + { title: 'Report', detail: 'fable: ranked findings + convergence table' }, + ], +} + +// ---------- config ---------- +// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce +const ARGS = (() => { + if (typeof args === 'string') { + try { return JSON.parse(args) || {} } catch { return {} } + } + return args || {} +})() +const MAX_ROUNDS = ARGS.maxRounds || 8 +const DRY_THRESHOLD = ARGS.dryThreshold || 2 +// ponytail: rough floor for one round (up to 12 high-effort finders + verifiers); tune after live runs +const ROUND_BUDGET_FLOOR = 150000 +// The args channel has already been observed delivering something the script +// could not read; an unnoticed fallback here is an 8x cost surprise, so say out +// loud what the run is actually going to do. +log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}`) + +// ---------- schemas: copied VERBATIM from the current bughunt.js ---------- +const FINDINGS = { + type: 'object', + required: ['findings'], + properties: { + findings: { + type: 'array', + items: { + type: 'object', + required: ['title', 'file', 'line', 'severity', 'why', 'repro'], + properties: { + title: { type: 'string' }, + file: { type: 'string', description: 'repo-relative path' }, + line: { type: 'integer' }, + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + why: { type: 'string', description: 'the defect, one or two sentences' }, + repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' }, + evidence: { type: 'string', description: 'the code lines that prove it' }, + }, + }, + }, + }, +} + +const VERDICTS = { + type: 'object', + required: ['verdicts'], + properties: { + verdicts: { + type: 'array', + items: { + type: 'object', + required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'], + properties: { + title: { type: 'string' }, + file: { type: 'string' }, + line: { type: 'integer' }, + refuted: { type: 'boolean' }, + reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + fix: { type: 'string', description: 'smallest correct fix, if confirmed' }, + }, + }, + }, + }, +} + +// ---------- rules ---------- +const RULES = ` +Repo: OwnCord, at D:/Local-Lab/Repos/OwnCord. Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/ +(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/). + +You are hunting REAL BUGS: wrong behavior, not style. In scope: + - logic errors, off-by-one, wrong operator, inverted condition, wrong default + - concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU + - lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers + - state machines that can reach an unintended state, or desync between two sources of truth + - error paths that silently swallow, lose data, or leave partial writes + - auth/authz checks reading stale state, or missing on one path while present on siblings + +Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening, +performance that is not a hang, anything you cannot point at specific lines for. + +Method: + 1. Read the actual files. Never report from a filename or a grep hit alone. + 2. For every candidate, grep for ALL callers before judging - a guard may already live upstream. + 3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it, + it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts. + 4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a + valid, respectable answer, and each finding needs file, line, and a concrete repro. + +You may run read-only shell commands (grep, git log, go doc). Do not modify any file. Do not run the test suite. +` + +// ---------- lens catalog ---------- +// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses. +const SURFACE_LENSES = [ + { + key: 'ws-hub', + prompt: + `Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` + + `client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` + + `Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` + + `the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` + + `read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` + + `invert under concurrent publish; pruner racing the persister over the same rows.\n` + + `Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`, + }, + { + key: 'voice-e2ee', + prompt: + `Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` + + `any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` + + `livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` + + `Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` + + `TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` + + `joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` + + `falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` + + `This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`, + }, + { + key: 'api-authz', + prompt: + `Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` + + `Server/permissions/*.go.\n\n` + + `Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` + + `a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` + + `request body when it should come from the session; sibling handlers where one path has a guard and a ` + + `near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` + + `that returns allow on error instead of deny.\n` + + `Compare handlers against each other - the strongest signal here is inconsistency between siblings.`, + }, + { + key: 'db-storage', + prompt: + `Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` + + `Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` + + `Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` + + `a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` + + `real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` + + `(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` + + `an older row shape; unbounded result sets read fully into memory.\n` + + `Read the .sql alongside its Go caller - the bug is usually the gap between them.`, + }, + { + key: 'tauri-rust', + prompt: + `Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` + + `Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` + + `environment-controlled input) - a panic here can take down the app; a lock held across .await; ` + + `state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` + + `ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` + + `left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` + + `For each panic you find, state exactly which input reaches it.`, + }, + { + key: 'client-state', + prompt: + `Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` + + `src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` + + `navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` + + `Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` + + `disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` + + `an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` + + `count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` + + `(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` + + `Check tests/unit/ before reporting - much of this behavior is already test-locked.`, + }, +] + +const BUGCLASS_LENSES = [ + { + key: 'concurrency', + prompt: + `Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` + + `Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` + + `missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` + + `Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` + + `coordination; Arc> cloned into tasks that outlive their owner.\n` + + `TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` + + `an await); a stale closure writing state after a newer update already landed.\n` + + `Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`, + }, + { + key: 'lifecycle', + prompt: + `Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` + + `Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` + + `timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` + + `pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` + + `Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` + + `partial teardown when an error interrupts the happy path halfway.`, + }, + { + key: 'state-desync', + prompt: + `Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` + + `Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` + + `stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` + + `any two structures updated by different code paths. Find the path that updates one and not the ` + + `other - reconnect, replacement, and error paths are where they diverge.`, + }, + { + key: 'error-paths', + prompt: + `Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` + + `Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` + + `partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` + + `an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` + + `Read every 'if err != nil', catch block, and .catch in the hot files from recon.`, + }, + { + key: 'ordering-boundary', + prompt: + `Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` + + `Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` + + `duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` + + `first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` + + `caller and its callee (read the SQL alongside its Go caller).`, + }, +] + +const FLOW_LENSES = [ + { + key: 'flow-reconnect', + prompt: + `Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` + + `reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` + + `transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` + + `transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` + + `re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` + + `reconcile after resume. Report any state that exists on the old connection and does not provably ` + + `reach the new one.`, + }, + { + key: 'flow-voice', + prompt: + `Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` + + `leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` + + `client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` + + `still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` + + `reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`, + }, + { + key: 'flow-message', + prompt: + `Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` + + `client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` + + `persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` + + `seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`, + }, + { + key: 'flow-session', + prompt: + `Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` + + `paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` + + `re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` + + `racing an in-flight request; API tokens diverging from session-token semantics on any path.`, + }, +] + +function lensesForRound(round) { + if (round === 1) return SURFACE_LENSES + if (round === 2) return BUGCLASS_LENSES + if (round === 3) return FLOW_LENSES + return buildAdaptiveLenses() +} +function familyName(round) { + return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive' +} +function clusterOf(file) { + const parts = String(file).split('/') + return parts.slice(0, parts[0] === 'Client' ? 3 : 2).join('/') +} +function freshEyesLens() { + const files = churnFiles.filter((f) => !seen.some((s) => s.file === f)).slice(0, 10) + if (!files.length) return [] + return [ + { + key: 'fresh-eyes', + prompt: + `These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` + + `single finding in them - either they are clean or every lens so far walked past them. Read each ` + + `one IN FULL with fresh eyes and hunt for real bugs of any class:\n` + + files.map((f) => ` - ${f}`).join('\n'), + }, + ] +} +function buildAdaptiveLenses() { + const byCluster = {} + for (const c of confirmedAll) { + const cl = clusterOf(c.file) + if (!byCluster[cl]) byCluster[cl] = [] + byCluster[cl].push(c) + } + const top = Object.entries(byCluster) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 3) + const hotspots = top.map(([cluster, items]) => ({ + key: ('hotspot ' + cluster).toLowerCase().replace(/[^a-z0-9]+/g, '-'), + prompt: + `Bugs cluster. Confirmed findings so far in ${cluster}:\n` + + items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') + + `\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` + + `(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` + + `here did NOT cover. Do not re-report the findings listed above - they are already known.`, + })) + return [...hotspots, ...freshEyesLens()] +} + +// ---------- dedupe + ledger helpers ---------- +function normTitle(t) { + return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2) +} +// Dedupe is permanent: a candidate merged into an existing entry never comes +// back, so an over-eager match silently loses a real bug rather than deferring +// it. The title-word branch therefore only applies near the prior's location - +// two distinct bugs in one file often share half their title words ("hub client +// map race on register" vs "...on unregister"), and without a window the second +// one is suppressed forever, sometimes by a merely REFUTED namesake. +const TITLE_MATCH_WINDOW = 60 +function isDup(a, b) { + if (a.file !== b.file) return false + const delta = Math.abs((a.line || 0) - (b.line || 0)) + if (delta <= 10) return true + if (delta > TITLE_MATCH_WINDOW) return false + const aw = normTitle(a.title) + if (!aw.length) return false + const bw = new Set(normTitle(b.title)) + const hits = aw.filter((w) => bw.has(w)).length + return hits * 2 >= aw.length +} +function dedupe(cands, priors) { + const kept = [] + for (const c of cands) { + if (priors.some((p) => isDup(c, p)) || kept.some((k) => isDup(c, k))) continue + kept.push(c) + } + return kept +} +function seenBlock(seen) { + if (!seen.length) return '' + const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`) + return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n` +} +function convergenceTable(stats, converged, stoppedOnBudget) { + const verdict = converged + ? `CONVERGED after ${stats.length} round(s).` + : stoppedOnBudget + ? 'NOT converged - stopped on budget.' + : 'NOT converged - hit the round backstop.' + const rows = stats.map( + (s) => + `| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`, + ) + return [ + '## Convergence', + '', + verdict, + '', + '| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |', + '|---|---|---|---|---|---|---|---|---|', + ...rows, + ].join('\n') +} + +// ---------- recon (verbatim from the current script, including both prompts) ---------- +phase('Recon') +const recon = await parallel([ + () => + agent( + `${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` + + `Run: git -C D:/Local-Lab/Repos/OwnCord log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` + + `Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` + + `plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` + + `Return plain text: one "path count" per line, most-churned first. No commentary.`, + { label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'low' }, + ), + () => + agent( + `${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` + + `Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` + + ` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` + + ` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` + + ` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` + + `For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`, + { label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'low' }, + ), +]) +const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n` +const churnFiles = String(recon[0] || '') + .split('\n') + .map((l) => l.trim().split(/\s+/)[0]) + .filter((p) => p.includes('/')) +log('Recon complete - starting converging rounds') + +// ---------- round loop ---------- +const seen = [] +const confirmedAll = [] +const unverified = [] +const roundStats = [] +const cleanStreak = {} +let dry = 0 +let round = 0 +let stoppedOnBudget = false + +function finderPrompt(lens, rnd) { + return ( + `${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` + + `KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` + + `NOT on that list.\n\n${lens.prompt}` + ) +} +function verifyPrompt(lensKey, candidates) { + return ( + `${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` + + `produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` + + `For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` + + `caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` + + ` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` + + ` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` + + ` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` + + ` 4. Is this intended behavior that a test already asserts?\n\n` + + `Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` + + `positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` + + `that prove the bug and describe how it fires.\n` + + `Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` + + `correct fix - one guard in the shared function beats a guard in every caller.\n\n` + + `Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` + + `--- CANDIDATES ---\n${JSON.stringify(candidates, null, 2)}` + ) +} + +while (dry < DRY_THRESHOLD && round < MAX_ROUNDS) { + if (budget.total && budget.remaining() < ROUND_BUDGET_FLOOR) { + stoppedOnBudget = true + log(`Budget floor reached (${Math.round(budget.remaining() / 1000)}k left) - stopping before round ${round + 1}`) + break + } + const family = lensesForRound(round + 1) + if (!family || !family.length) break // nothing to hunt != everything demoted + round++ + const lenses = family.filter((l) => (cleanStreak[l.key] || 0) < 2) + if (!lenses.length) { + dry++ + roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry }) + log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`) + continue + } + const rnd = round + const seenAtStart = seen.slice() + const lensResults = await pipeline( + lenses, + (lens) => + parallel([ + () => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS }), + () => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:sonnet`, phase: `Round ${rnd}`, model: 'sonnet', effort: 'high', schema: FINDINGS }), + ]).then((pair) => ({ lens, pair })), + async (r) => { + const { lens, pair } = r + const finderFailed = pair.some((p) => p === null) + const union = pair.filter(Boolean).flatMap((p) => p.findings || []) + const fresh = dedupe(union, seenAtStart) + if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], verdicts: [] } + log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`) + const vopts = { phase: `Round ${rnd}`, model: 'fable', effort: 'high', schema: VERDICTS } + let v = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` }) + if (!v || (v.verdicts || []).length < fresh.length) { + const retry = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` }) + if (((retry && retry.verdicts) || []).length > ((v && v.verdicts) || []).length) v = retry + } + return { lens, finderFailed, unionCount: union.length, fresh, verdicts: (v && v.verdicts) || [] } + }, + ) + + let eligible = !lensResults.some((r) => !r) + let newConfirmed = 0 + let newRefuted = 0 + let candCount = 0 + let freshCount = 0 + for (const r of lensResults.filter(Boolean)) { + candCount += r.unionCount + freshCount += r.fresh.length + if (r.finderFailed) eligible = false + let lensConfirmed = 0 + const unmatched = r.fresh.slice() + for (const v of r.verdicts) { + const vRec = { file: v.file, line: v.line, title: v.title } + const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec)) + if (idx === -1) { + log(`r${round} ${r.lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) matched no candidate - dropped`) + continue + } + unmatched.splice(idx, 1) + const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' } + if (seen.some((p) => isDup(rec, p))) continue // cross-lens same-round duplicate + seen.push(rec) + if (v.refuted) newRefuted++ + else { + newConfirmed++ + lensConfirmed++ + confirmedAll.push({ ...v, lens: r.lens.key, round }) + } + } + if (unmatched.length) { + eligible = false // partial verifier failure: some candidates got no verdict at all + for (const f of unmatched) unverified.push({ ...f, lens: r.lens.key, round }) + } + // a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness + if (!r.finderFailed && !unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1 + } + + if (newConfirmed > 0) dry = 0 + else if (eligible) dry++ + // ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean" + roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry }) + log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`) +} + +const converged = dry >= DRY_THRESHOLD + +// ---------- report ---------- +phase('Report') +const RANK = { critical: 0, high: 1, medium: 2, low: 3 } +const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity]) +const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p))) +const table = convergenceTable(roundStats, converged, stoppedOnBudget) + +let report +if (!confirmedSorted.length && !unverifiedFinal.length) { + const outcome = converged ? 'Converged' : stoppedOnBudget ? 'Stopped on budget - NOT converged' : 'Hit the round backstop - NOT converged' + report = `${outcome} after ${round} round(s) with zero confirmed findings.\n\n${table}` +} else { + report = await agent( + `You are writing the final bug-hunt report for OwnCord (D:/Local-Lab/Repos/OwnCord).\n\n` + + `The findings below already survived adversarial verification - do NOT re-litigate them, and do NOT add new ` + + `ones. Your job is presentation and prioritization for a maintainer who will fix these today.\n\n` + + `Spot-check the two highest-severity findings against the real files to make sure file paths and line numbers ` + + `are accurate; correct them silently if they drifted.\n\n` + + `Write markdown:\n` + + ` - Open with one paragraph: how many real bugs, in which subsystems, whether the hunt CONVERGED, and which ` + + `finding to fix first and why.\n` + + ` - Then one section per finding, ordered by severity: a "### - " heading, the ` + + `\`file:line\` reference, what breaks and under exactly what conditions, and the smallest correct fix.\n` + + (unverifiedFinal.length + ? ` - Then an "## Unverified - re-run" section listing these candidates whose verification failed twice: ` + + `${JSON.stringify(unverifiedFinal)}\n` + : '') + + ` - End with the convergence table below, VERBATIM.\n` + + `Write in complete sentences. No emoji, no "consider" hedging.\n\n` + + `--- CONFIRMED FINDINGS ---\n${JSON.stringify(confirmedSorted, null, 2)}\n\n` + + `--- CONVERGENCE TABLE ---\n${table}`, + { label: 'report', phase: 'Report', model: 'fable', effort: 'high' }, + ) +} + +return { converged, stoppedOnBudget, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, report } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c4bb1000..8d98d305 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -17,6 +17,10 @@ - [ ] Unit tests pass (`npm test` / `go test ./...`) - [ ] TypeScript check passes (`npx tsc --noEmit`) - [ ] Manual testing done (describe below) +- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose + "Source of truth" files this PR touches is updated in the same PR + (their maintenance rule), and reference docs (`api.md`, `protocol.md`, + `schema.md`, `server-configuration.md`) reflect any surface changes ## Screenshots diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fb5ea52..97ed961e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,19 @@ jobs: - name: Run tests with deadlock detection run: go test -tags deadlock -count=1 ./... + # Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above + # only COMPILES the otel/wazero variants; the tests behind those tags + # (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran + # nowhere until this step. Scoped to the two packages that carry tagged + # files — every other package is tag-invariant and already covered by the + # race run above. One leg is enough; no -race (the runtime under the tag + # is the concern, not new concurrency). + - name: Run tag-gated tests (-tags wazero, -tags otel) + if: matrix.os == 'ubuntu-latest' + run: | + go test -tags wazero -count=1 ./plugin/... + go test -tags otel -count=1 ./telemetry/... + - name: Upload Go coverage if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -107,25 +120,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Patch auto-generated Tauri TypeScript bindings - working-directory: Client/tauri-client/ - # tauri-typegen generates an Event type that is intentionally unused in app code. - # Rename it to _Event so @typescript-eslint/no-unused-vars does not fail. - run: | - node -e " - const fs = require('fs'); - const p = 'src/generated/events.ts'; - if (fs.existsSync(p)) { - let c = fs.readFileSync(p, 'utf8'); - c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event'); - c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event - fs.writeFileSync(p, c); - console.log('Patched: renamed Event -> _Event in generated/events.ts'); - } else { - console.log('src/generated/events.ts not found, skipping patch.'); - } - " - # Scoped to shipped dependencies. The remaining high findings are all one # advisory, brace-expansion <=5.0.7, reaching us only through dev tooling # (eslint, @vitest/coverage-v8, stryker). Those are already on their @@ -144,6 +138,12 @@ jobs: - name: TypeScript check run: npx tsc --noEmit + - name: TypeScript check (Playwright specs) + # The main tsconfig excludes tests/e2e from the app graph; this + # project typechecks the 47 spec files + fixtures + the three + # playwright configs so type rot cannot hide there. + run: npx tsc -p tsconfig.e2e.json --noEmit + - name: ESLint (type-aware rules) run: npx eslint src/ @@ -151,7 +151,9 @@ jobs: run: npx prettier --check "src/**/*.ts" "tests/**/*.ts" - name: Knip (unused code & deps) - run: npx knip || true + # Blocking since the 2026-08-04 remediation: the '|| true' era let a + # real unused-export finding sit invisible in every green run. + run: npx knip # Unit tests live in their own job so a suite failure is visible as exactly one # failing check instead of masking the static gates above. The suite is GREEN @@ -228,24 +230,23 @@ jobs: - name: Rust unit tests run: cargo test --lib - # Playwright e2e against the mocked-Tauri dev server. The suite is green - # since the mock repair (start_http_proxy stub + voice-premise rewrite): - # a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway - # protection lives in playwright.config.ts (maxFailures: 20 aborts a - # systemic cascade early; globalTimeout: 20 min self-terminates with a - # usable report) with timeout-minutes below as the outer backstop. + # Playwright e2e against the mocked-Tauri dev server. Runaway protection + # lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade + # early; globalTimeout: 20 min self-terminates with a usable report) with + # timeout-minutes below as the outer backstop. # - # Still continue-on-error for now: a newly-revived 255-test browser suite - # may harbor rare flakes (retries: 2 covers them, but confidence needs a - # few green pushes first). Flip this job to blocking once it has been - # stably green across several pushes. + # BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green + # full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit + # branches, and the one hard CI failure in that window was a real spec bug + # (updater install-settle race), which a non-blocking job would have let + # rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's + # flake accounting). # See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21. # 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) + name: Client E2E (Playwright) runs-on: ubuntu-latest - continue-on-error: true timeout-minutes: 25 defaults: run: @@ -278,6 +279,53 @@ jobs: Client/tauri-client/test-results/ retention-days: 7 + # Admin-panel journey against a REAL server (no mocks): start-server.sh + # builds the Go binary and boots it with a fresh temp data dir, and the + # suite drives the embedded SPA through the first-run wizard, dashboard, + # channel CRUD, audit log and re-login — the one DC-04 surface the mocked + # suites cannot reach. Non-blocking while it earns its soak, same + # graduation convention client-e2e followed. + admin-e2e: + name: Admin Panel E2E (real server, non-blocking) + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 20 + defaults: + run: + working-directory: Client/tauri-client/ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26" + cache-dependency-path: Server/go.sum + + - 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 admin-panel journey + run: npx playwright test --config=playwright.config.admin.ts + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: admin-e2e-report + path: | + Client/tauri-client/playwright-report/ + Client/tauri-client/test-results/ + retention-days: 7 + # Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering # the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW # gate, group DMs, role change, custom-emoji autocomplete, voice moderation). @@ -414,30 +462,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Install tauri-typegen - run: cargo install tauri-typegen@0.5.0 --quiet - - - name: Generate TypeScript IPC bindings - working-directory: Client/tauri-client/ - run: cargo tauri-typegen generate - - - name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround) - working-directory: Client/tauri-client/ - # tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation. - # Duplicate events are avoided at source by using one emit() call site per event name. - run: | - node -e " - const fs = require('fs'); - const tp = fs.readFileSync('src/generated/types.ts', 'utf8'); - if (!tp.includes('export type Value')) { - fs.writeFileSync('src/generated/types.ts', tp.replace( - 'export interface CredentialData', - 'export type Value = unknown;\n\nexport interface CredentialData' - )); - } - console.log('Generated bindings patched.'); - " - - name: Clippy lint (Rust) working-directory: Client/tauri-client/src-tauri/ run: cargo clippy -- -D warnings diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f..a0e8cb67 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,13 +26,13 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@9db594c7a0e82298c121c18b7f08aa1579ce7341 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index 8f4a009a..1eab611b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,13 @@ .env Server/.env -# Claude Code — local-only, never committed. A slashless pattern matches at any -# depth, so these also cover Server/CLAUDE.md, Client/**/CLAUDE.md and nested -# .claude/ dirs. -.claude/ -CLAUDE.md +# Claude Code — local-only by default. The exceptions are committed on purpose: +# a cloud session clones this repo and sees ONLY tracked files, so the CLAUDE.md +# files, skills and workflows have to be here or it starts with no instructions. +# Machine-local state (settings.local.json, locks) stays ignored. +.claude/* +!.claude/skills/ +!.claude/workflows/ CLAUDE.local.md .mcp.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 68227642..0b8ab47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,83 @@ tooling (`npm run changelog`) auto-generates entries from commit messages on each release; this file is the curated counterpart that calls out behavioural changes operators must know about. +## Unreleased + +- **fix(client):** the user profile popup is styled correctly again + (`a308f81`). +- **fix(client):** Vite no longer watches `src-tauri/`, so a running dev + server does not rebuild the frontend when Rust sources or build artifacts + change (`cdcfc03`). +- **fix(release):** the stripped Linux AppImage is signed from the + environment-provided key instead of a temporary key file (`9d75890`) — + release-pipeline only, no operator action needed. +- **docs:** full documentation audit against `5630aa1` — reference docs, + architecture pages, and UX specs corrected; plans and prior audits given + verified statuses; see `docs/audit-2026-08-04-docs-and-coverage.md`. +- **security(server):** closed the three 2026-08-04 review findings — the + channel role-override **DELETE** now enforces the same hierarchy guard as + PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer + sees DM channels, answering 404 for their ids (A-2026-08-02); DM call + rings respect blocks like every other DM interaction (A-2026-08-03). + Behavioural note: deleting a channel override for a *nonexistent* role now + returns 404 (was 204), matching PUT. +- **server:** migration **029** drops the never-used `sounds` table (dead + since the initial schema; A-2026-07-13). Applies automatically on first + start; no operator action. +- **protocol:** the plugin command family (`chat_command`, `command_reply`, + `plugin_broadcast`) is now part of `protocol-schema.json` and the + generated constants (27 client→server / 39 server→client). Wire strings + are unchanged — no client or plugin impact. +- **chore(client):** dead modules deleted (`ServerStrip`, `FileUpload`, + `reconcile`, a stray worklet copy, orphan sounds API methods) and the + unused tauri-typegen pipeline retired (`src/generated/**`, its CI steps, + config block, and build-dependency). +- **ci:** knip is now blocking; Playwright specs are typechecked + (`typecheck:e2e`); three orphaned native e2e specs run again; + `claude.yml` actions are SHA-pinned; the PR template asks for docs + updates per the architecture maintenance rule. +- **tests(client):** the TOFU certificate ceremony has e2e coverage + (first-use + mismatch journeys), and `modalFactory` is fully covered. +- **security(client):** the voice-E2EE identity pin lookup fails **closed** + on keyring errors (DC-08): a transient store failure used to read as + "never pinned", silently sending a pinned peer down the first-sight path + and re-pinning whatever key the server delivered. An unreadable pin store + now rejects the peer's announce, writes nothing, and shows a distinct + amber "could not check" badge until the store recovers. +- **feat(client):** accessibility pass over the modal/overlay stack + (DC-13): every modal is a labelled `role="dialog"` with a focus trap and + focus restore, Escape maps to each dialog's safe action, the settings + sidebar is a keyboard-navigable tablist, the quick switcher and composer + autocompletes are wired as combobox/listbox, the emoji/GIF pickers are + keyboard-operable, and toasts/typing announce via polite live regions. +- **feat(client):** UX polish (DC-12): deleting the active channel now + says so in a toast; reactions toggle optimistically with rollback on + failure; the role-change menu can no longer double-fire; a document-level + listener leak in channel drag-reorder is fixed. +- **feat(admin):** restoring a backup now writes a `backup_restore` + audit-log row (DC-09). The row is written before the pre-restore safety + copy, so it lives inside the `pre_restore_*.db` backup — the restored + database itself cannot carry it (the restore replaces the file). +- **ci:** the `-tags wazero` / `-tags otel` Go tests now actually run in CI + (DC-06) — previously those variants were only compiled, leaving ~600 + lines of plugin/telemetry tests permanently dark. +- **tests(client):** e2e journeys for voice-E2EE identity verification + (badge states + mismatch modal, driven through the real crypto path) and + the updater (banner → progress → auto-relaunch), plus an accessibility + smoke; full web suite now 291 tests. + +- **server/admin:** in-place self-update is refused in container + deployments (503 `CONTAINER_DEPLOYMENT`; the shipped image sets + `OWNCORD_CONTAINER=1`, bind-mount operators can set `0` to opt back in). + Container upgrades are image pulls; `GET /admin/api/updates` now reports + `can_apply` and the admin panel says so instead of offering the button. +- **ci:** the full client e2e suite now blocks merges (DC-07); a new + non-blocking `admin-e2e` job drives the admin panel against a real server + (first-run wizard, channel CRUD, audit log, re-login). +- **docs:** the dependency pinning/review policy is written down in + `docs/contributing.md`, closing the last 2026-04 audit carryover that was + still undecided. + ## v1.2.0-alpha.1 — Discord feature parity > **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is @@ -256,6 +333,6 @@ claimed behaviour — no product code changed and no assertion weakened. The project is under a feature freeze until the beta reset completes. Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK wiring, the Postgres backend (scaffolding removed pending real demand), -the slash-command dispatcher (`docs/plans/slash-commands.md`), and the -Solid.js migration (abandoned — the experiment is being removed in favor -of the established vanilla component pattern). +and the slash-command dispatcher (`docs/plans/slash-commands.md`). The +Solid.js migration was abandoned and its experiment fully removed +(2026-07-19) in favor of the established vanilla component pattern. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f6601396 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# OwnCord + +Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket +server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri +v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail +lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol +and schema are documented in `docs/protocol.md`, `docs/schema.md`, and +`docs/architecture/README.md`. + +## Generated code — never hand-edit + +CI fails on drift, and the next generator run silently discards your edit. + +| Generated | Source of truth | Workflow | +| --- | --- | --- | +| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill | +| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill | +| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` | + +## Gotchas + +- **Verify with the `ci-check` skill**, not with an ad-hoc `go build && go test`. + CI compiles four Go build-tag variants and runs a deadlock-detection pass; + the default build proves nothing about the tagged ones. +- **The client unit suite is green and must stay green.** Never make a failing + test pass by weakening its assertions. +- Security issues go through GitHub Security Advisories, never public issues + (`docs/security.md`). This repo is public — unfixed defects do not belong in + commits, issues, or PR descriptions. +- Branch from `main`, PR to `main`, squash merge, conventional commit subjects. diff --git a/Client/tauri-client/.nvmrc b/Client/tauri-client/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/Client/tauri-client/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/Client/tauri-client/CLAUDE.md b/Client/tauri-client/CLAUDE.md new file mode 100644 index 00000000..ad19bf0f --- /dev/null +++ b/Client/tauri-client/CLAUDE.md @@ -0,0 +1,32 @@ +# OwnCord Client (Tauri v2) + +TypeScript frontend (Vite, vanilla TS — no React/Vue) plus a deliberately thin +Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video. + +## Layout + +- `src/stores/` observable stores · `src/lib/` protocol, WS, voice, E2EE · + `src/pages/`, `src/components/` UI +- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root + CLAUDE.md +- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) · + `tests/browser` (vitest browser mode) + +## Gotchas + +- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.** + Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that + have nothing to do with your change. That is a local toolchain artifact, not + a regression — do not "fix" those failures. CI pins Node 20. +- `src/lib/dispatcher.ts` is the single WS-event entry point: server events + reach the stores only through a `ws.on(...)` subscription registered there. +- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry + points check whether a newer attempt owns the shared state before tearing + anything down, so cleanup in an aborted path must be scoped to that attempt's + own room — a global `leaveVoice()` there kills the live session. +- Voice E2EE is key-holder based with TOFU identity pinning. Anything touching + `livekitE2EE.ts` or `identity.ts` must preserve the epoch/keypair staleness + guards and must never report an unverified peer as verified. +- Do not run `npm run tauri build` locally; the desktop build is CI-only. +- Formatting is prettier-enforced; match the surrounding code rather than + reasoning about style. diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index f2272f15..2a3c6f4c 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -28,6 +28,7 @@ "@stryker-mutator/typescript-checker": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", "@tauri-apps/cli": "^2", + "@types/node": "^20.19.43", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", "eslint": "^10.8.0", @@ -2078,9 +2079,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2098,9 +2096,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2118,9 +2113,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2138,9 +2130,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2158,9 +2147,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2178,9 +2164,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2198,9 +2181,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2218,9 +2198,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3882,6 +3859,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -7285,6 +7272,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index 8a3da401..8754bb98 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -14,12 +14,14 @@ "test:e2e": "playwright test", "test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts", "test:e2e:native": "playwright test --config playwright.config.native.ts", + "test:e2e:admin": "playwright test --config playwright.config.admin.ts", "test:e2e:ui": "playwright test --ui", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:browser": "vitest run --config vitest.config.browser.ts", "typecheck": "tsc --noEmit", "typecheck:build": "tsc -p tsconfig.build.json --noEmit", + "typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit", "lint": "oxlint src/ && eslint src/", "lint:fix": "eslint src/ --fix", "lint:ox": "oxlint src/", @@ -37,6 +39,7 @@ "@stryker-mutator/typescript-checker": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", "@tauri-apps/cli": "^2", + "@types/node": "^20.19.43", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", "eslint": "^10.8.0", diff --git a/Client/tauri-client/playwright.config.admin.ts b/Client/tauri-client/playwright.config.admin.ts new file mode 100644 index 00000000..984b38ce --- /dev/null +++ b/Client/tauri-client/playwright.config.admin.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "@playwright/test"; + +/** + * Playwright config for the ADMIN PANEL e2e suite — the server-embedded SPA + * (Server/admin/static/index.html), driven against a REAL server started by + * tests/e2e/admin/start-server.sh (fresh temp data dir, TLS off, loopback). + * + * Unlike the mocked-Tauri web suite this exercises the true stack: chi + * router, admin middleware/gates, SQLite, and the SPA itself. The journey is + * stateful by design (first-run wizard creates the owner the later tests log + * in as), so it runs serially in one worker against one server instance. + * + * Usage: npm run test:e2e:admin (requires the Go toolchain) + */ +const PORT = process.env.OWNCORD_ADMIN_E2E_PORT ?? "18446"; + +export default defineConfig({ + testDir: "./tests/e2e/admin", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 1, + reporter: process.env.CI + ? [ + ["html", { open: "never" }], + ["junit", { outputFile: "test-results/admin-junit.xml" }], + ] + : "html", + + use: { + baseURL: `http://127.0.0.1:${PORT}`, + screenshot: "only-on-failure", + trace: "on-first-retry", + contextOptions: { reducedMotion: "reduce" }, + }, + + webServer: { + command: "bash tests/e2e/admin/start-server.sh", + url: `http://127.0.0.1:${PORT}/health`, + reuseExistingServer: !process.env.CI, + // First run compiles the Go server; CI cold caches need the headroom. + timeout: 240_000, + }, +}); diff --git a/Client/tauri-client/playwright.config.native.ts b/Client/tauri-client/playwright.config.native.ts index 8f8f6351..e16bf244 100644 --- a/Client/tauri-client/playwright.config.native.ts +++ b/Client/tauri-client/playwright.config.native.ts @@ -60,7 +60,10 @@ export default defineConfig({ "app-layout.spec.ts", "channel-navigation.spec.ts", "chat-operations.spec.ts", + "dm-system.spec.ts", + "reconnection.spec.ts", "settings-overlay.spec.ts", + "theme-persistence.spec.ts", "voice-controls.spec.ts", "overlays.spec.ts", ], diff --git a/Client/tauri-client/playwright.config.prod.ts b/Client/tauri-client/playwright.config.prod.ts index f7c1a757..8f5f4ff9 100644 --- a/Client/tauri-client/playwright.config.prod.ts +++ b/Client/tauri-client/playwright.config.prod.ts @@ -9,7 +9,7 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/native/**"], + testIgnore: ["**/native/**", "**/admin/**"], timeout: 30_000, expect: { timeout: 5_000, diff --git a/Client/tauri-client/playwright.config.ts b/Client/tauri-client/playwright.config.ts index f15575bc..b7c47bb9 100644 --- a/Client/tauri-client/playwright.config.ts +++ b/Client/tauri-client/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/native/**"], + testIgnore: ["**/native/**", "**/admin/**"], timeout: 30_000, expect: { timeout: 5_000, diff --git a/Client/tauri-client/public/rnnoise-worklet.ts b/Client/tauri-client/public/rnnoise-worklet.ts deleted file mode 100644 index 1293d5e1..00000000 --- a/Client/tauri-client/public/rnnoise-worklet.ts +++ /dev/null @@ -1,291 +0,0 @@ -// ============================================================================= -// RNNoise AudioWorklet Processor -// -// Runs on the audio rendering thread. Receives WASM module bytes from the -// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz. -// ============================================================================= - -const FRAME_SIZE = 480; -const WASM_MEMORY_INITIAL_PAGES = 256; -const OUTPUT_RING_CAPACITY = 50; -const RN_NOISE_INT16_SCALE = 32768; - -declare abstract class AudioWorkletProcessor { - readonly port: MessagePort; -} - -declare function registerProcessor( - name: string, - processorCtor: typeof RNNoiseProcessor, -): void; - -interface RNNoiseWasmExports extends WebAssembly.Exports { - rnnoise_create(): number; - rnnoise_destroy(state: number): void; - rnnoise_process_frame(state: number, outputPtr: number, inputPtr: number): void; - malloc(size: number): number; - free(ptr: number): void; -} - -interface RNNoiseWasmInstance extends WebAssembly.Instance { - exports: RNNoiseWasmExports; -} - -class RNNoiseProcessor extends AudioWorkletProcessor { - private _instance: RNNoiseWasmInstance | null = null; - private _state: number = 0; - private _inputPtr: number = 0; - private _outputPtr: number = 0; - private _heapF32: Float32Array | null = null; - private _ready: boolean = false; - private _destroyed: boolean = false; - - // Ring buffer to accumulate 480-sample frames - private _inputRing: Float32Array; - private _inputRingOffset: number = 0; - - // Output ring buffer (contiguous for efficiency) - private _outBuffer: Float32Array; - private _outWritePos: number = 0; - private _outReadPos: number = 0; - private _outAvailable: number = 0; - private _outSampleOffset: number = 0; - - constructor() { - super(); - - this._inputRing = new Float32Array(FRAME_SIZE); - this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE); - - this.port.onmessage = (event: MessageEvent) => { - if (event.data.type === "init") { - this._initWasm(event.data.wasmBytes); - } else if (event.data.type === "destroy") { - this._cleanup(); - } - }; - } - - /** - * Reports an error to the main thread and logs it. - * @param message - Error message - * @param error - Optional error object - * @private - */ - private _reportError(message: string, error?: unknown): void { - console.error(`RNNoise Processor: ${message}`, error); - this.port.postMessage({ type: "error", message }); - } - - /** - * Initializes the WASM module and RNNoise state. - * @param wasmBytes - Raw WASM module bytes - * @private - */ - private async _initWasm(wasmBytes: ArrayBuffer): Promise<void> { - let allocated = false; - try { - // Basic validation: check for expected exports - const module = await WebAssembly.compile(wasmBytes); - const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free']; - const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name); - - const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp)); - if (!hasRequiredExports) { - throw new Error('WASM module missing required RNNoise exports'); - } - - const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES }); - const importObject = { - env: { - memory, - emscripten_notify_memory_growth: () => { - this._heapF32 = new Float32Array(memory.buffer); - }, - }, - wasi_snapshot_preview1: { - proc_exit: () => {}, - fd_close: () => 0, - fd_write: () => 0, - fd_seek: () => 0, - }, - }; - - // Try instantiating with the raw WASM bytes - const { instance } = await WebAssembly.instantiate(wasmBytes, importObject); - this._instance = instance as RNNoiseWasmInstance; - this._heapF32 = new Float32Array(memory.buffer); - - // Call RNNoise C API - const exports = instance.exports as unknown as RNNoiseWasmExports; - this._state = exports.rnnoise_create(); - this._inputPtr = exports.malloc(FRAME_SIZE * 4); - this._outputPtr = exports.malloc(FRAME_SIZE * 4); - allocated = true; - - this._ready = true; - this.port.postMessage({ type: "ready" }); - } catch (err) { - // Cleanup allocated memory on failure - if (allocated && this._instance) { - try { - const exports = this._instance.exports; - if (this._inputPtr) exports.free(this._inputPtr); - if (this._outputPtr) exports.free(this._outputPtr); - if (this._state) exports.rnnoise_destroy(this._state); - } catch (cleanupErr) { - // Log cleanup errors but don't override original error - console.warn('Failed to cleanup WASM memory:', cleanupErr); - } - } - this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err); - } - } - - /** - * Processes a complete 480-sample frame through RNNoise. - * Copies input ring buffer to WASM memory, runs noise suppression, - * and stores the result in the output ring buffer. - * @private - */ - private _processFrame(): void { - if (!this._instance || !this._heapF32) return; - const exports = this._instance.exports; - - const inOff = this._inputPtr / 4; - const outOff = this._outputPtr / 4; - - // CRITICAL: Bounds check before accessing heap - if (inOff + FRAME_SIZE > this._heapF32.length || - outOff + FRAME_SIZE > this._heapF32.length) { - console.error('WASM heap bounds exceeded'); - return; - } - - for (let i = 0; i < FRAME_SIZE; i++) { - this._heapF32[inOff + i] = (this._inputRing[i] ?? 0) * RN_NOISE_INT16_SCALE; - } - - exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr); - - // Write to contiguous buffer - const writeStart = this._outWritePos * FRAME_SIZE; - for (let i = 0; i < FRAME_SIZE; i++) { - this._outBuffer[writeStart + i] = (this._heapF32[outOff + i] ?? 0) / RN_NOISE_INT16_SCALE; - } - this._outWritePos = (this._outWritePos + 1) % OUTPUT_RING_CAPACITY; - if (this._outAvailable < OUTPUT_RING_CAPACITY) { - this._outAvailable++; - } else { - // Overwrite oldest - this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY; - this._outSampleOffset = 0; - } - } - - /** - * Cleans up WASM resources and marks the processor as destroyed. - * Safe to call multiple times. - * @private - */ - private _cleanup(): void { - if (this._instance && this._state) { - try { - const exports = this._instance.exports; - exports.rnnoise_destroy(this._state); - exports.free(this._inputPtr); - exports.free(this._outputPtr); - } catch (err) { - console.warn('RNNoise cleanup failed:', err); - // Continue cleanup even if individual steps fail - } - } - this._ready = false; - this._destroyed = true; - this._state = 0; - } - - /** - * Processes input audio data into the ring buffer and triggers frame processing. - * @param inData - Input audio samples - * @private - */ - private _processInputRingBuffer(inData: Float32Array): void { - let inIdx = 0; - while (inIdx < inData.length) { - const needed = FRAME_SIZE - this._inputRingOffset; - const toCopy = Math.min(needed, inData.length - inIdx); - this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset); - this._inputRingOffset += toCopy; - inIdx += toCopy; - - if (this._inputRingOffset >= FRAME_SIZE) { - this._processFrame(); - this._inputRingOffset = 0; - } - } - } - - /** - * Fills output buffer from the processed frames ring buffer. - * @param outData - Output audio buffer to fill - * @private - */ - private _fillOutputFromRingBuffer(outData: Float32Array): void { - let outIdx = 0; - while (outIdx < outData.length && this._outAvailable > 0) { - const readStart = this._outReadPos * FRAME_SIZE; - const available = FRAME_SIZE - this._outSampleOffset; - const toWrite = Math.min(available, outData.length - outIdx); - outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx); - outIdx += toWrite; - this._outSampleOffset += toWrite; - if (this._outSampleOffset >= FRAME_SIZE) { - this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY; - this._outAvailable--; - this._outSampleOffset = 0; - } - } - // Fill remaining with silence - if (outIdx < outData.length) { - outData.fill(0, outIdx); - } - } - - process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean { - if (this._destroyed) return false; - - // Validate input/output structure - if (!inputs || !inputs[0] || !inputs[0][0] || - !outputs || !outputs[0] || !outputs[0][0]) { - return true; // Pass through silence or existing data - } - - const input = inputs[0]; - const output = outputs[0]; - const inData = input[0]!; - const outData = output[0]!; - - // Validate buffer lengths - if (inData.length === 0 || outData.length === 0) { - return true; - } - - if (!this._ready) { - // Pass through until WASM is ready - const copyLength = Math.min(inData.length, outData.length); - outData.set(inData.subarray(0, copyLength)); - if (copyLength < outData.length) { - outData.fill(0, copyLength); - } - return true; - } - - this._processInputRingBuffer(inData); - this._fillOutputFromRingBuffer(outData); - - return true; - } -} - -registerProcessor("rnnoise-processor", RNNoiseProcessor); diff --git a/Client/tauri-client/src-tauri/.cargo/audit.toml b/Client/tauri-client/src-tauri/.cargo/audit.toml index 46b22dc5..7ae4584d 100644 --- a/Client/tauri-client/src-tauri/.cargo/audit.toml +++ b/Client/tauri-client/src-tauri/.cargo/audit.toml @@ -8,4 +8,28 @@ ignore = [ # Drop both entries when the notification chain moves to quick-xml >= 0.41. "RUSTSEC-2026-0194", "RUSTSEC-2026-0195", + + # glib 0.18.5 is pinned by the whole Linux GTK stack: wry (even 0.56) + # requires `webkit2gtk =2.0.2`, which requires `glib ^0.18.0`. The fix + # landed in glib 0.20.0 and was never backported (0.18.5 is the last 0.18 + # release; 0.19.x is still in range), so no semver-compatible route exists. + # The unsoundness is only reachable through `Variant::array_iter_str()` — + # nothing in the dependency tree or in src-tauri/src/ calls it, and the + # crate is Linux-only here (see the cfg(target_os = "linux") block in + # Cargo.toml). Drop this entry when webkit2gtk moves to gtk-rs 0.20. + "RUSTSEC-2024-0429", + + # rand 0.7.3 arrives only as a BUILD dependency, three levels down: + # tauri-utils -> kuchikiki 0.8.8-speedreader -> selectors 0.24.0, whose + # build.rs uses phf_codegen -> phf_generator 0.8.0 (which requires + # rand ^0.7). It runs at codegen time and never links into a shipped + # binary. The advisory needs `ThreadRng` reseeding under a custom logger + # with rand's `log` feature on; phf_generator instead uses a fixed-seed + # `SmallRng::seed_from_u64(1234567890)` and never enables `log` — and no + # other crate here depends on rand 0.7, so feature unification cannot + # turn it on. Not upgradable: kuchikiki 0.8.9-speedreader would drop this + # chain, but cargo will not match a pre-release across patch versions + # (`^0.8.8-speedreader` rejects 0.8.9-speedreader) and tauri-utils 2.9.3 + # is the latest release. Drop this entry when tauri-utils bumps kuchikiki. + "RUSTSEC-2026-0097", ] diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index c722fa59..27e266c5 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -69,56 +69,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -423,16 +373,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -603,35 +543,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link 0.2.1", ] -[[package]] -name = "chrono-tz" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" -dependencies = [ - "chrono", - "chrono-tz-build", - "phf 0.11.3", -] - -[[package]] -name = "chrono-tz-build" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" -dependencies = [ - "parse-zoneinfo", - "phf 0.11.3", - "phf_codegen 0.11.3", -] - [[package]] name = "cipher" version = "0.4.4" @@ -642,52 +558,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -707,18 +577,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] - [[package]] name = "const-random" version = "0.1.18" @@ -860,25 +718,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1087,12 +926,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "deunicode" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" - [[package]] name = "device_query" version = "2.1.0" @@ -1310,12 +1143,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1874,30 +1701,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags 2.11.0", - "ignore", - "walkdir", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -2110,15 +1913,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2331,22 +2125,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2370,19 +2148,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.18.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - [[package]] name = "infer" version = "0.19.0" @@ -2437,12 +2202,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itoa" version = "1.0.18" @@ -2626,12 +2385,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.14" @@ -3208,12 +2961,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "open" version = "5.3.3" @@ -3301,7 +3048,6 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", - "tauri-typegen", "tokio", "tokio-rustls", "tokio-tungstenite", @@ -3367,15 +3113,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "parse-zoneinfo" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" -dependencies = [ - "regex", -] - [[package]] name = "pathdiff" version = "0.2.3" @@ -3388,49 +3125,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "phf" version = "0.8.0" @@ -3692,12 +3386,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -4582,12 +4270,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-rename-rule" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3" - [[package]] name = "serde-untagged" version = "0.1.9" @@ -4820,16 +4502,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slug" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" -dependencies = [ - "deunicode", - "wasm-bindgen", -] - [[package]] name = "smallvec" version = "1.15.1" @@ -5568,27 +5240,6 @@ dependencies = [ "wry", ] -[[package]] -name = "tauri-typegen" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e761d97bf6c90f13894383493485008d0c51f67ff7b12c0f95dc34b8a9e6e73" -dependencies = [ - "chrono", - "clap", - "indicatif", - "proc-macro2", - "quote", - "regex", - "serde", - "serde-rename-rule", - "serde_json", - "syn 2.0.117", - "tera", - "thiserror 2.0.18", - "walkdir", -] - [[package]] name = "tauri-utils" version = "2.9.3" @@ -5685,28 +5336,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tera" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" -dependencies = [ - "chrono", - "chrono-tz", - "globwalk", - "humansize", - "lazy_static", - "percent-encoding", - "pest", - "pest_derive", - "rand 0.8.7", - "regex", - "serde", - "serde_json", - "slug", - "unicode-segmentation", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6134,12 +5763,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "uds_windows" version = "1.2.1" @@ -6204,24 +5827,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "untrusted" version = "0.9.0" @@ -6265,12 +5876,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.22.0" diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 3d001158..0238af4f 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -21,7 +21,6 @@ crate-type = ["lib", "cdylib", "staticlib"] [build-dependencies] tauri-build = { version = "2", features = [] } -tauri-typegen = "0.5" [features] default = [] diff --git a/Client/tauri-client/src-tauri/src/fallback_crypto.rs b/Client/tauri-client/src-tauri/src/fallback_crypto.rs index 69b1ba77..5315dcfa 100644 --- a/Client/tauri-client/src-tauri/src/fallback_crypto.rs +++ b/Client/tauri-client/src-tauri/src/fallback_crypto.rs @@ -67,12 +67,9 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { options.mode(0o600); } match options.open(&path) { - Ok(mut file) => { - file.write_all(&key) - .and_then(|()| file.sync_all()) - .map_err(|e| format!("failed to write credential fallback key: {e}"))?; - Ok(key) - } + Ok(mut file) => finish_new_key_file(&path, key, || { + file.write_all(&key).and_then(|()| file.sync_all()) + }), // Lost the create race to another thread — use the winner's key. Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { let bytes = fs::read(&path) @@ -86,6 +83,28 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { } } +/// Finish writing a just-created (empty) key file: run `write_and_sync` — the +/// real write_all + sync_all in production, injected here so the failure +/// path is testable without forcing a genuine disk-full/IO error — and +/// delete the file again if it fails. +/// +/// `create_new` above already created `path` with zero bytes in it. Left +/// behind, a write/sync failure leaves a short file that every future +/// `load_or_create_key` call reads back and rejects forever (see this +/// function's doc comment: "never rewritten once it exists") — silently +/// poisoning the fallback store on the first ENOSPC/IO hiccup. +fn finish_new_key_file( + path: &Path, + key: [u8; KEY_LEN], + write_and_sync: impl FnOnce() -> std::io::Result<()>, +) -> Result<[u8; KEY_LEN], String> { + if let Err(e) = write_and_sync() { + let _ = fs::remove_file(path); + return Err(format!("failed to write credential fallback key: {e}")); + } + Ok(key) +} + /// Seal `plaintext` under `key`, binding `aad` (the service + account name). /// /// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random @@ -217,6 +236,34 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn removes_the_partial_file_when_the_write_fails() { + // A crash / ENOSPC mid-write must not leave a short file behind: + // load_or_create_key's doc comment says the key file is "never + // rewritten once it exists", so a poisoned short file is permanent — + // every future load fails the length check forever. + let dir = std::env::temp_dir().join(format!( + "owncord-fallback-partial-write-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE); + // `create_new` in load_or_create_key already created this empty file + // before the write step (which is what's under test) runs. + fs::write(&path, b"").unwrap(); + + let err = finish_new_key_file(&path, [7u8; KEY_LEN], || { + Err(std::io::Error::other("disk full")) + }) + .unwrap_err(); + + assert!(err.contains("failed to write"), "unexpected error: {err}"); + assert!(!path.exists(), "a failed write must not leave a partial key file behind"); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn rejects_a_corrupt_key_file() { let dir = std::env::temp_dir().join(format!( diff --git a/Client/tauri-client/src-tauri/src/http_proxy.rs b/Client/tauri-client/src-tauri/src/http_proxy.rs index 2f3bed43..a0b2a2a7 100644 --- a/Client/tauri-client/src-tauri/src/http_proxy.rs +++ b/Client/tauri-client/src-tauri/src/http_proxy.rs @@ -33,7 +33,7 @@ use std::collections::HashMap; use std::net::IpAddr; use std::sync::Arc; use rustls::pki_types::ServerName; -use tauri::{AppHandle, Runtime}; +use tauri::{AppHandle, Manager, Runtime}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; @@ -57,6 +57,18 @@ impl HttpProxyState { inner: Mutex::new(HashMap::new()), } } + + /// Remove the `remote_host` entry, but only if it still points at `port`. + /// Used by `run_proxy_loop`'s accept-error exit path to deregister a dead + /// tunnel without racing a newer tunnel that may have already replaced it + /// (e.g. `stop_http_proxy` + a fresh `start_http_proxy` while this loop + /// was mid-shutdown). + async fn remove_if_port_matches(&self, remote_host: &str, port: u16) { + let mut inner = self.inner.lock().await; + if inner.get(remote_host).is_some_and(|entry| entry.port == port) { + inner.remove(remote_host); + } + } } /// Validate a remote host string before it is used in header rewriting and @@ -111,6 +123,7 @@ pub async fn start_http_proxy<R: Runtime>( app.clone(), listener, remote_host.clone(), + port, shutdown_rx, )); // Watch the loop so a panic is logged instead of vanishing silently (which @@ -161,6 +174,7 @@ async fn run_proxy_loop<R: Runtime>( app: AppHandle<R>, listener: TcpListener, remote_host: String, + port: u16, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { let mut consecutive_errors: u32 = 0; @@ -191,6 +205,21 @@ async fn run_proxy_loop<R: Runtime>( "[http_proxy] {} consecutive accept errors, stopping proxy loop", MAX_CONSECUTIVE_ACCEPT_ERRORS ); + // Deregister the dead tunnel BEFORE the break drops + // `listener`, so a future start_http_proxy rebinds a + // fresh port instead of handing back this closed one + // forever. Doing it here rather than after the loop + // returns matters: the listener still holds the port, + // so no newer tunnel can have been handed the same + // number and the port guard cannot misfire. + if let Some(state) = app.try_state::<HttpProxyState>() { + state.remove_if_port_matches(&remote_host, port).await; + } else { + warn!( + "[http_proxy] state unmanaged; cannot deregister dead tunnel for {}", + remote_host + ); + } break; } } @@ -408,6 +437,45 @@ async fn handle_connection<R: Runtime>( mod tests { use super::*; + // Regression: the accept-error exit path in run_proxy_loop must be able to + // deregister its own dead entry, but must NOT clobber a newer tunnel that + // has since replaced it under the same remote_host key. + #[tokio::test] + async fn remove_if_port_matches_removes_only_matching_entry() { + let state = HttpProxyState::new(); + { + let (tx, _rx) = tokio::sync::oneshot::channel::<()>(); + let mut inner = state.inner.lock().await; + inner.insert( + "example.com:8443".to_string(), + ProxyEntry { + port: 4242, + shutdown_tx: tx, + }, + ); + } + + // A stale loop reporting a port that no longer matches the live + // entry must leave the current entry alone. + state + .remove_if_port_matches("example.com:8443", 9999) + .await; + assert_eq!( + state.inner.lock().await.get("example.com:8443").map(|e| e.port), + Some(4242), + "mismatched port must not remove a newer tunnel's entry" + ); + + // A loop reporting its own still-current port must remove it. + state + .remove_if_port_matches("example.com:8443", 4242) + .await; + assert!( + state.inner.lock().await.get("example.com:8443").is_none(), + "matching port must deregister the dead tunnel" + ); + } + #[test] fn validate_rejects_crlf_and_null() { assert!(validate_remote_host("evil\r\nhost").is_err()); diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 56d4cf01..e7c9ad2f 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -125,6 +125,7 @@ pub fn run() { ptt::ptt_stop, ptt::ptt_set_key, ptt::ptt_get_key, + ptt::ptt_polling_supported, ptt::ptt_listen_for_key, livekit_proxy::start_livekit_proxy, livekit_proxy::stop_livekit_proxy, diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index 444e0809..bdbf8465 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -18,7 +18,9 @@ // - Certificate validation uses the TOFU-pinned fingerprint from ws_proxy. // The WebSocket proxy must connect first to establish trust; the LiveKit // proxy then pins to that same certificate. If the cert changes between -// WS and LiveKit connections, the LiveKit handshake will fail. +// WS and LiveKit connections, the LiveKit handshake will fail (fail +// closed) until the user accepts the new cert — each start call reloads +// the stored pin and restarts the listener when it changed. // - Only one proxy instance runs at a time (per remote host). Connecting to // a different server replaces the proxy. Stale proxy ports are not reused. // - If the TcpListener errors (extremely unlikely on loopback), the cached @@ -45,6 +47,9 @@ struct ProxyInner { port: Option<u16>, /// The remote host:port we're proxying to. remote_host: String, + /// The TOFU fingerprint the running listener pins. Baked into the proxy + /// loop at spawn, so a re-pin in the cert store requires a restart. + pinned_fingerprint: String, /// Shutdown signal sender. shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>, } @@ -55,6 +60,7 @@ impl LiveKitProxyState { inner: Mutex::new(ProxyInner { port: None, remote_host: String::new(), + pinned_fingerprint: String::new(), shutdown_tx: None, }), } @@ -121,6 +127,21 @@ pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String modified } +/// Decide whether an already-running proxy can serve a new start request: +/// only when both the remote host AND the TOFU-pinned fingerprint are +/// unchanged. The listener bakes its fingerprint in at spawn, so after the +/// user accepts a rotated cert (which rewrites the store), reusing the old +/// listener would fail every TLS handshake against the stale pin until +/// logout — the caller must tear down and restart instead. +pub(crate) fn can_reuse_proxy( + running_host: &str, + running_fingerprint: &str, + requested_host: &str, + stored_fingerprint: &str, +) -> bool { + running_host == requested_host && running_fingerprint == stored_fingerprint +} + /// Extract the TLS server name from a `host[:port]` string. /// /// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and @@ -162,24 +183,12 @@ pub async fn start_livekit_proxy<R: Runtime>( info!("[livekit_proxy] start requested for {}", remote_host); - // Reuse existing proxy for same host. - if let Some(port) = inner.port { - if inner.remote_host == remote_host { - debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host); - return Ok(port); - } - // Different host — tear down old proxy. - info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host); - if let Some(tx) = inner.shutdown_tx.take() { - let _ = tx.send(()); - } - inner.port = None; - } - - // Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must - // have connected first (establishing the TOFU trust), so the fingerprint - // should already be stored. If not, reject — we refuse to connect without - // a pinned cert. + // Load the TOFU-pinned fingerprint from the cert store BEFORE the reuse + // check — a running listener bakes its pin in at spawn, so a re-pin + // (user accepted a rotated cert) must force a restart, not a reuse. The + // ws_proxy must have connected first (establishing the TOFU trust), so + // the fingerprint should already be stored. If not, reject — we refuse + // to connect without a pinned cert. let store_key = tofu::cert_store_key(&remote_host); let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)? .ok_or_else(|| format!( @@ -187,6 +196,23 @@ pub async fn start_livekit_proxy<R: Runtime>( Connect via WebSocket first to establish TOFU trust." ))?; + // Reuse the existing proxy only when host AND pin are unchanged. + if let Some(port) = inner.port { + if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) { + debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host); + return Ok(port); + } + // Different host or re-pinned cert — tear down the old proxy. + info!( + "[livekit_proxy] stopping old proxy for {} (restarting for {})", + inner.remote_host, remote_host + ); + if let Some(tx) = inner.shutdown_tx.take() { + let _ = tx.send(()); + } + inner.port = None; + } + let listener = TcpListener::bind("127.0.0.1:0") .await .map_err(|e| format!("livekit proxy bind failed: {e}"))?; @@ -198,7 +224,7 @@ pub async fn start_livekit_proxy<R: Runtime>( let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let host = remote_host.clone(); - let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx)); + let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint.clone(), shutdown_rx)); // Watch the loop so a panic is logged instead of vanishing silently. tokio::spawn(async move { match loop_handle.await { @@ -212,6 +238,7 @@ pub async fn start_livekit_proxy<R: Runtime>( inner.port = Some(port); inner.remote_host = remote_host; + inner.pinned_fingerprint = fingerprint; inner.shutdown_tx = Some(shutdown_tx); Ok(port) @@ -228,6 +255,7 @@ pub async fn stop_livekit_proxy( } inner.port = None; inner.remote_host.clear(); + inner.pinned_fingerprint.clear(); Ok(()) } @@ -282,6 +310,35 @@ async fn run_proxy_loop( } } +/// Bound on the outbound dial and TLS handshake, matching http_proxy.rs. +const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Dial `remote_host` and complete the TLS handshake, bounding each step by +/// `limit`. +/// +/// Both steps must be bounded. A peer that accepts the TCP connection and then +/// never answers the ClientHello blocks the handshake forever, and the calling +/// task holds `local` without polling it — so the LiveKit SDK closing its side +/// never cancels it. Those tasks and their sockets accumulate on every SDK +/// retry and survive stop_livekit_proxy, whose shutdown oneshot only stops the +/// accept loop; the per-connection tasks are detached. +async fn connect_tls( + connector: &tokio_rustls::TlsConnector, + server_name: ServerName<'static>, + remote_host: &str, + limit: Duration, +) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> { + debug!("[livekit_proxy] connecting TCP to {}", remote_host); + let tcp = timeout(limit, TcpStream::connect(remote_host)) + .await + .map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??; + debug!("[livekit_proxy] starting TLS handshake with {}", remote_host); + let tls = timeout(limit, connector.connect(server_name, tcp)) + .await + .map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??; + Ok(tls) +} + /// Handle a single proxied connection: /// 1. Read the HTTP request headers from the local (plain) side /// 2. Rewrite Host/Origin so the remote server accepts the connection @@ -344,10 +401,7 @@ async fn handle_connection( let server_name = parse_server_name(remote_host)?; - debug!("[livekit_proxy] connecting TCP to {}", remote_host); - let tcp = TcpStream::connect(remote_host).await?; - debug!("[livekit_proxy] starting TLS handshake with {}", remote_host); - let mut tls = connector.connect(server_name, tcp).await?; + let mut tls = connect_tls(&connector, server_name, remote_host, PROXY_CONNECT_TIMEOUT).await?; debug!("[livekit_proxy] TLS handshake complete, forwarding traffic"); // ── 4. Forward request + bidirectional copy ────────────────────────── @@ -431,6 +485,27 @@ mod tests { assert!(validate_remote_host("").is_ok()); } + // ── can_reuse_proxy ───────────────────────────────────────────────────── + + #[test] + fn reuses_proxy_only_when_host_and_pin_are_unchanged() { + assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb")); + } + + #[test] + fn restarts_proxy_when_host_changes() { + assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb")); + } + + #[test] + fn restarts_proxy_when_pin_changes() { + // The user accepted a rotated cert (accept_cert_fingerprint rewrote the + // store). The running listener still pins the old fingerprint, so every + // connection through it would fail the TLS handshake — reuse must be + // refused so the caller tears down and restarts with the new pin. + assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd")); + } + // ── rewrite_proxy_headers ─────────────────────────────────────────────── #[test] @@ -558,4 +633,48 @@ mod tests { fn rejects_an_invalid_dns_name() { assert!(parse_server_name("not a hostname").is_err()); } + + // A peer that accepts the TCP connection and then answers nothing must not + // hang the connection task forever — see connect_tls. + #[tokio::test] + async fn tls_handshake_is_bounded_by_its_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + tokio::spawn(async move { + let _accepted = listener.accept().await.expect("accept"); + // Hold the connection open, answering nothing. + std::future::pending::<()>().await; + }); + + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new( + "aa:bb:cc".to_string(), + ))) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + let server_name = ServerName::try_from("localhost").expect("server name"); + + // The outer bound exists only so a regression fails fast instead of + // hanging the suite; the assertion is that the inner limit fired. + let outcome = timeout( + Duration::from_secs(5), + connect_tls( + &connector, + server_name, + &addr.to_string(), + Duration::from_millis(100), + ), + ) + .await; + + assert!( + outcome.is_ok(), + "connect_tls hung: the TLS handshake is not bounded by its own timeout" + ); + assert!( + outcome.expect("bounded").is_err(), + "a silent peer must produce an error, not a usable TLS stream" + ); + } } diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index 60522a6e..6a53650c 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -298,10 +298,55 @@ mod linux { } } +/// Decide whether the polling loop must emit a `ptt-state` event this tick. +/// +/// Returns `Some(new_state)` on a press/release edge, `None` when nothing +/// changed. +/// +/// The `vk == 0` (unbound) case is folded into `pressed` here rather than +/// guarding the whole tick: clearing the binding while the key is physically +/// held must still produce the `true -> false` falling edge. With the guard +/// outside, `was_pressed` freezes at `true`, no final `ptt-state=false` is +/// ever emitted, and the microphone stays published. +fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> { + let pressed = vk != 0 && key_down; + (pressed != was_pressed).then_some(pressed) +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- +/// Whether this platform can actually observe global key state, i.e. whether +/// the polling loop can ever emit a `ptt-state` event. +/// +/// `ptt_start` spawns its thread unconditionally, so a live thread is NOT +/// evidence that PTT works: on macOS `is_key_down` is a compile-time stub that +/// always returns false, and on a pure-Wayland Linux session +/// `DeviceState::checked_new()` returns None. The frontend gates its join-time +/// PTT mute on this, because muting at join where no event can ever arrive +/// would close the microphone for the whole session with no way to reopen it. +#[tauri::command] +pub fn ptt_polling_supported() -> bool { + #[cfg(windows)] + { + true + } + + #[cfg(target_os = "linux")] + { + use device_query::DeviceState; + // Mirrors the availability check inside `is_key_down`: no reachable + // X11/XWayland display means key state is never observable. + DeviceState::checked_new().is_some() + } + + #[cfg(not(any(windows, target_os = "linux")))] + { + false + } +} + /// Start the PTT polling loop. Emits `ptt-state` (bool) events. /// /// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate @@ -329,12 +374,14 @@ pub fn ptt_start<R: Runtime>(app: AppHandle<R>) { while !thread_shutdown.load(Ordering::SeqCst) { let vk = PTT_VKEY.load(Ordering::SeqCst); - if vk != 0 { - let pressed = is_key_down(vk); - if pressed != was_pressed { - was_pressed = pressed; - let _ = app.emit("ptt-state", pressed); - } + // Evaluated on every tick, including vk == 0: clearing the PTT + // key while it is physically held must still produce a falling + // edge, otherwise was_pressed freezes at true and the mic never + // gets its final `ptt-state=false`. `is_key_down` short-circuits + // to false for vk == 0 on every platform, so this costs nothing. + if let Some(pressed) = ptt_transition(vk, is_key_down(vk), was_pressed) { + was_pressed = pressed; + let _ = app.emit("ptt-state", pressed); } std::thread::sleep(Duration::from_millis(20)); } @@ -523,6 +570,31 @@ mod tests { assert!(g.is_none(), "slot must stay empty when nothing was running"); } + // The loop must emit only on edges, never on every tick — a repeat emit + // would re-run the mute logic (and its user-mute guard) 50x/second. + #[test] + fn ptt_transition_reports_edges_only() { + assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge"); + assert_eq!(ptt_transition(0x41, true, true), None, "still held"); + assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge"); + assert_eq!(ptt_transition(0x41, false, false), None, "still idle"); + } + + // Regression for the "hot mic after Clear while the PTT key is held" bug: + // clearing the binding (vk -> 0) with the key still physically down must + // still yield the falling edge that emits the final ptt-state=false. The + // old loop wrapped the whole comparison in `if vk != 0`, so this case + // produced no transition at all and the mic stayed published. + #[test] + fn ptt_transition_emits_release_when_binding_cleared_while_key_held() { + assert_eq!(ptt_transition(0, true, true), Some(false)); + // The release is reported once, then the unbound key stays quiet — an + // unbound key must never read as pressed no matter what the raw + // key-down probe says. + assert_eq!(ptt_transition(0, true, false), None); + assert_eq!(ptt_transition(0, false, false), None); + } + #[test] fn allowed_capture_vk_accepts_safe_non_text_keys() { assert!(is_allowed_ptt_capture_vk(0x70)); // F1 diff --git a/Client/tauri-client/src-tauri/src/secret_store.rs b/Client/tauri-client/src-tauri/src/secret_store.rs index 6d7453fb..fd9be779 100644 --- a/Client/tauri-client/src-tauri/src/secret_store.rs +++ b/Client/tauri-client/src-tauri/src/secret_store.rs @@ -92,6 +92,28 @@ const FALLBACK_BACKEND: Backend = Backend::EncryptedFile; /// secret — the caller's in-memory copy is all that is left, so the current /// session still works but nothing survives a restart. pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, String> { + set_with( + account, + secret, + keyring_set, + keyring_get, + keyring_delete, + |acct, sec| set_fallback(app, acct, sec), + |acct| clear_fallback(app, acct), + ) +} + +/// Core decision logic for [`set`], with the keyring and fallback operations +/// injected so the branching is testable without a live OS credential store. +fn set_with( + account: &str, + secret: &str, + keyring_set: impl Fn(&str, &str) -> Result<(), String>, + keyring_get: impl Fn(&str) -> Result<Option<String>, String>, + keyring_delete: impl Fn(&str) -> Result<(), String>, + fallback_set: impl FnOnce(&str, &str) -> Result<(), String>, + fallback_clear: impl FnOnce(&str), +) -> Result<Backend, String> { match keyring_set(account, secret) { Ok(()) => match keyring_get(account) { // The normal path: written and read back byte-for-byte. @@ -99,7 +121,7 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri // A machine that was previously degraded and has since been // fixed must not keep a stale ciphertext shadowing the real // store on the next read. - clear_fallback(app, account); + fallback_clear(account); return Ok(Backend::Keyring); } Ok(Some(_)) => { @@ -127,10 +149,23 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri but the read-back failed: {e} — falling back" ), }, - Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"), + Err(e) => { + log::error!("{SERVICE}: credential store write failed for '{account}': {e}"); + // An older secret may already sit in the keyring from a prior + // successful write. get() reads the keyring first, so leaving + // that stale entry in place would shadow the fresh secret parked + // in the fallback below — mirrors the read-back-mismatch arm + // above, which purges for the same reason. + if let Err(de) = keyring_delete(account) { + log::warn!( + "{SERVICE}: could not remove a stale keyring entry for '{account}' after a \ + failed write: {de}" + ); + } + } } - set_fallback(app, account, secret)?; + fallback_set(account, secret)?; log::warn!( "{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \ credential store. See docs/credential-storage.md" @@ -143,12 +178,34 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri /// The OS credential store wins over the fallback file, so a machine that /// recovers goes back to the real store without any migration step. pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> { + get_with(account, keyring_get, |acct| get_fallback(app, acct)) +} + +/// Core decision logic for [`get`], with the keyring and fallback lookups +/// injected so the branching is testable without a live OS credential store. +fn get_with( + account: &str, + keyring_get: impl Fn(&str) -> Result<Option<String>, String>, + get_fallback: impl Fn(&str) -> Option<String>, +) -> Result<Option<String>, String> { match keyring_get(account) { - Ok(Some(secret)) => return Ok(Some(secret)), - Ok(None) => {} - Err(e) => log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"), + Ok(Some(secret)) => Ok(Some(secret)), + Ok(None) => Ok(get_fallback(account)), + Err(e) => { + log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"); + // A read error must not collapse to "nothing stored": on a + // healthy machine set() clears the fallback on every successful + // write, so an empty fallback here is indistinguishable from + // "never stored". Prefer a fallback copy if one exists; only + // report "nothing" when both stores genuinely have nothing, and + // otherwise propagate the error so the caller can tell a broken + // store apart from first login. + match get_fallback(account) { + Some(secret) => Ok(Some(secret)), + None => Err(e), + } + } } - Ok(get_fallback(app, account)) } /// Remove `account` from every store. Absent entries are not an error. @@ -413,6 +470,90 @@ mod tests { assert_eq!(fallback_aad("host.example"), fallback_aad("host.example")); } + // -- get_with: finding "a keyring read error must not read as 'not stored'" -- + + #[test] + fn get_with_falls_back_when_the_keyring_errors_but_the_fallback_has_a_copy() { + let result = get_with( + "identity:chat.example", + |_| Err("keychain locked".to_string()), + |_| Some("fallback-secret".to_string()), + ); + assert_eq!(result, Ok(Some("fallback-secret".to_string()))); + } + + #[test] + fn get_with_propagates_the_keyring_error_when_the_fallback_is_also_empty() { + // The bug: a keyring read failure must never be reported as "nothing + // stored" (Ok(None)) when the fallback is empty too — that is + // indistinguishable from first login, and the E2EE identity keypair + // loader mints and publishes a brand-new identity key on exactly that + // signal, invalidating every peer's TOFU pin. + let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None); + assert_eq!(result, Err("keychain locked".to_string())); + } + + #[test] + fn get_with_prefers_the_live_keyring_value_over_the_fallback() { + let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string())); + assert_eq!(result, Ok(Some("live".to_string()))); + } + + #[test] + fn get_with_uses_the_fallback_when_the_keyring_has_nothing_stored() { + let result = get_with("acct", |_| Ok(None), |_| Some("fallback".to_string())); + assert_eq!(result, Ok(Some("fallback".to_string()))); + } + + // -- set_with: finding "a failed keyring write must not leave a stale entry" -- + + #[test] + fn set_with_deletes_any_stale_keyring_entry_when_the_write_fails() { + // The bug: a write failure with an older secret already sitting in + // the keyring from a prior successful write must not leave that + // stale entry in place — get() reads the keyring first, so it would + // shadow the fresh secret parked in the fallback below forever. + use std::cell::Cell; + let delete_called = Cell::new(false); + let result = set_with( + "acct", + "new-secret", + |_, _| Err("write failed".to_string()), + |_| panic!("keyring_get must not run after a failed write"), + |_| { + delete_called.set(true); + Ok(()) + }, + |_, _| Ok(()), + |_| {}, + ); + assert_eq!(result, Ok(FALLBACK_BACKEND)); + assert!( + delete_called.get(), + "a failed keyring write must delete any stale prior entry before falling back" + ); + } + + #[test] + fn set_with_returns_keyring_backend_when_the_write_round_trips() { + use std::cell::Cell; + let cleared = Cell::new(false); + let result = set_with( + "acct", + "secret", + |_, s| { + assert_eq!(s, "secret"); + Ok(()) + }, + |_| Ok(Some("secret".to_string())), + |_| panic!("must not delete a keyring entry that round-tripped"), + |_, _| panic!("must not touch the fallback on a successful round trip"), + |_| cleared.set(true), + ); + assert_eq!(result, Ok(Backend::Keyring)); + assert!(cleared.get(), "a recovered machine must clear any stale fallback copy"); + } + #[cfg(windows)] #[test] fn dpapi_round_trips_and_rejects_foreign_entropy() { diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index ba377415..a4911c2f 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -13,6 +13,7 @@ use futures_util::{SinkExt, StreamExt}; use log::{debug, error, info, warn}; use serde_json::Value; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Emitter, Runtime}; @@ -32,14 +33,68 @@ use crate::tofu::{self, TofuOutcome}; /// into its closure and clear the sender even after a worker task panic. pub struct WsState { tx: Arc<Mutex<Option<mpsc::Sender<String>>>>, + /// Bumped once per `ws_connect` attempt. The handshake can pend for up to + /// CONNECT_TIMEOUT, and callers (profile switch) start a second connect + /// without awaiting the first, so an attempt must prove it is still the + /// current generation before it may touch the shared sender slot. + generation: Arc<AtomicU64>, } impl WsState { pub fn new() -> Self { Self { tx: Arc::new(Mutex::new(None)), + generation: Arc::new(AtomicU64::new(0)), } } + + /// Claim a generation for a new connection attempt, dropping any existing + /// sender. Every later step of that attempt is conditional on this value + /// still being current. + async fn begin_connection(&self) -> u64 { + let mut tx_lock = self.tx.lock().await; + if tx_lock.is_some() { + debug!("[ws_proxy] dropping existing connection"); + } + *tx_lock = None; + self.generation.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Install `tx` as the live sender if `generation` is still current. + /// Returns false when a newer `ws_connect` superseded this attempt. + async fn install_sender(&self, generation: u64, tx: mpsc::Sender<String>) -> bool { + // Checked under the slot lock so the decision and the write cannot be + // split by a concurrent attempt. + let mut tx_lock = self.tx.lock().await; + if self.generation.load(Ordering::SeqCst) != generation { + return false; + } + *tx_lock = Some(tx); + true + } +} + +/// Clear the live sender slot, but only if `my_generation` is still the +/// current connection generation. Returns false when a newer `ws_connect` +/// superseded this connection — that teardown must not clear the slot or +/// announce a close. Ownership is proven by generation, NOT by holding a +/// Sender clone: a clone kept alive in the monitor task would keep the +/// outbound channel open, so the write task could never observe closure +/// after `ws_disconnect` (circular wait — task, socket, and TLS session +/// would all leak). `generation` only advances inside `begin_connection` +/// while the slot lock is held, so checking it under the same lock makes +/// the check-and-clear atomic with respect to new attempts. +async fn clear_sender_if_current( + slot: &Mutex<Option<mpsc::Sender<String>>>, + generation: &AtomicU64, + my_generation: u64, +) -> bool { + let mut tx_lock = slot.lock().await; + if generation.load(Ordering::SeqCst) != my_generation { + return false; + } + *tx_lock = None; + true } /// Single call site for ws-state events — keeps tauri-typegen from generating duplicates. @@ -69,14 +124,8 @@ pub async fn ws_connect<R: Runtime>( ) -> Result<(), String> { info!("[ws_proxy] connecting to {}", url); - // Drop any existing connection - { - let mut tx_lock = state.tx.lock().await; - if tx_lock.is_some() { - debug!("[ws_proxy] dropping existing connection"); - } - *tx_lock = None; - } + // Drop any existing connection and claim this attempt's generation. + let my_generation = state.begin_connection().await; // Only allow secure WebSocket connections if !url.starts_with("wss://") { @@ -169,23 +218,26 @@ pub async fn ws_connect<R: Runtime>( } // ── End TOFU check ─────────────────────────────────────────────────── + let (mut sink, mut stream) = ws_stream.split(); + + // Channel for JS → server messages (bounded for backpressure). The slot + // gets the ONLY Sender: teardown ownership is proven by generation, so no + // clone may outlive the slot — one would keep rx.recv() pending forever. + let (tx, mut rx) = mpsc::channel::<String>(256); + if !state.install_sender(my_generation, tx).await { + info!("[ws_proxy] handshake superseded by a newer connect; dropping stale socket"); + return Err("superseded by a newer connection".into()); + } + info!("[ws_proxy] connected to {}", host); emit_ws_state(&app, "open"); - let (mut sink, mut stream) = ws_stream.split(); - - // Channel for JS → server messages (bounded for backpressure) - let (tx, mut rx) = mpsc::channel::<String>(256); - { - let mut tx_lock = state.tx.lock().await; - *tx_lock = Some(tx); - } - let app_read = app.clone(); let app_state = app.clone(); - // Clone the Arc so the monitoring closure can clear tx on any exit path, + // Clone the Arcs so the monitoring closure can clear tx on any exit path, // including worker task panics, without needing tauri::State. let tx_arc = Arc::clone(&state.tx); + let generation_arc = Arc::clone(&state.generation); // Single outer task owns a JoinSet containing read and write workers. // join_next() blocks until the first worker finishes (normally or via panic), @@ -242,14 +294,16 @@ pub async fn ws_connect<R: Runtime>( } // Clear the sender so ws_send returns "not connected". This runs on - // every exit path — normal close, graceful disconnect, and panic. - { - let mut tx_lock = tx_arc.lock().await; - *tx_lock = None; + // every exit path — normal close, graceful disconnect, and panic — but + // only when this connection still owns the slot. Clearing + // unconditionally would kill a newer connection's sender and tell JS + // that the live connection had closed. + if clear_sender_if_current(&tx_arc, &generation_arc, my_generation).await { + // Always emit closed, even after a panic. + emit_ws_state(&app_state, "closed"); + } else { + debug!("[ws_proxy] superseded connection torn down; leaving live sender in place"); } - - // Always emit closed, even after a panic. - emit_ws_state(&app_state, "closed"); }); Ok(()) @@ -427,4 +481,112 @@ mod tests { let bad = format!("é{}", &VALID[..93]); assert!(!is_valid_cert_fingerprint(&bad)); } + + // ── Connection-generation ownership of the shared sender slot ─────────── + // + // A handshake pends up to CONNECT_TIMEOUT, and a profile switch starts a + // second ws_connect without awaiting or cancelling the first, so two + // attempts can be in flight over one slot. Mirrors the ptt.rs + // ATOMICRACE-001 guard. + + #[tokio::test] + async fn superseded_connect_does_not_take_the_sender_slot() { + let state = WsState::new(); + + // Connection A starts its handshake, then a profile switch starts B + // while A is still pending. + let gen_a = state.begin_connection().await; + let gen_b = state.begin_connection().await; + assert_ne!(gen_a, gen_b); + + let (tx_b, _rx_b) = mpsc::channel::<String>(4); + assert!( + state.install_sender(gen_b, tx_b.clone()).await, + "the current generation must be able to install" + ); + + // A's handshake finally completes. Installing now would route the next + // auth send to the stale host and drop B's sender, ending B's write task. + let (tx_a, _rx_a) = mpsc::channel::<String>(4); + assert!( + !state.install_sender(gen_a, tx_a).await, + "a superseded attempt must not take the slot" + ); + + let slot = state.tx.lock().await; + assert!( + slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)), + "the live connection's sender must still be installed" + ); + } + + // ── Generation-owned teardown ─────────────────────────────────────────── + // + // Teardown ownership must be provable WITHOUT holding a Sender clone: any + // clone kept alive by the monitor task keeps the outbound channel open, so + // after ws_disconnect drops the slot's sender the write task never sees + // rx.recv() == None — writer, reader, and TLS socket all leak in a + // circular wait (monitor waits on writer, writer waits on the monitor's + // clone dropping). + + #[tokio::test] + async fn owning_teardown_clears_the_slot_by_generation() { + let state = WsState::new(); + let my_generation = state.begin_connection().await; + let (tx, _rx) = mpsc::channel::<String>(4); + state.install_sender(my_generation, tx).await; + + assert!( + clear_sender_if_current(&state.tx, &state.generation, my_generation).await, + "the owning connection must clear its slot without a Sender clone" + ); + assert!( + state.tx.lock().await.is_none(), + "ws_send must report not-connected after a real close" + ); + } + + #[tokio::test] + async fn superseded_teardown_by_generation_leaves_the_live_sender() { + let state = WsState::new(); + let gen_a = state.begin_connection().await; + let (tx_a, _rx_a) = mpsc::channel::<String>(4); + state.install_sender(gen_a, tx_a).await; + + let gen_b = state.begin_connection().await; + let (tx_b, _rx_b) = mpsc::channel::<String>(4); + assert!(state.install_sender(gen_b, tx_b.clone()).await); + + // A's monitor task tears down after B is live. Clearing here would + // kill B's sender and emit "closed" while JS believes B is connected. + assert!( + !clear_sender_if_current(&state.tx, &state.generation, gen_a).await, + "a superseded teardown must not clear the slot or announce a close" + ); + let slot = state.tx.lock().await; + assert!( + slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)), + "the live connection's sender must survive a superseded teardown" + ); + } + + #[tokio::test] + async fn disconnect_closes_the_outbound_channel() { + // ws_disconnect's contract (the comment at its *tx_lock = None): + // dropping the slot's sender closes the channel so the write task + // ends. That holds only while install_sender receives the ONLY + // Sender — no teardown-ownership clone may exist. + let state = WsState::new(); + let generation = state.begin_connection().await; + let (tx, mut rx) = mpsc::channel::<String>(4); + state.install_sender(generation, tx).await; + + *state.tx.lock().await = None; // ws_disconnect + + let got = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("write task would hang forever: channel still open after disconnect"); + assert_eq!(got, None, "rx.recv() must yield None so the write task exits"); + } + } diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 926263cb..2e450428 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -65,12 +65,6 @@ } }, "plugins": { - "tauri-typegen": { - "project_path": ".", - "output_path": "../src/generated", - "validation_library": "none", - "verbose": false - }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK", "endpoints": [], diff --git a/Client/tauri-client/src/components/AdminActions.ts b/Client/tauri-client/src/components/AdminActions.ts index 82ebe222..7149494e 100644 --- a/Client/tauri-client/src/components/AdminActions.ts +++ b/Client/tauri-client/src/components/AdminActions.ts @@ -231,6 +231,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont ); const roleSub = createElement("div", { class: "context-menu__submenu" }); + // One guard across every option: `currentRole` only updates when the + // member_update echoes, so without it a double-click (or a second option + // clicked while the first PATCH is in flight) fires twice. + let roleChangeRunning = false; for (const role of options.availableRoles) { const cls = role === options.currentRole @@ -240,9 +244,14 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont role, cls, () => { - if (role !== options.currentRole) { - void options.onChangeRole(role); - } + if (roleChangeRunning || role === options.currentRole) return; + roleChangeRunning = true; + roleOption.classList.add("context-menu__item--pending"); + const done = (): void => { + roleChangeRunning = false; + roleOption.classList.remove("context-menu__item--pending"); + }; + options.onChangeRole(role).then(done, done); }, ac.signal, ); diff --git a/Client/tauri-client/src/components/CertMismatchModal.ts b/Client/tauri-client/src/components/CertMismatchModal.ts index 9cbb278c..11b1c663 100644 --- a/Client/tauri-client/src/components/CertMismatchModal.ts +++ b/Client/tauri-client/src/components/CertMismatchModal.ts @@ -8,6 +8,7 @@ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import type { MountableComponent } from "@lib/safe-render"; export interface CertMismatchModalOptions { @@ -21,17 +22,27 @@ export interface CertMismatchModalOptions { export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent { const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options; let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; const ac = new AbortController(); function mount(container: Element): void { overlay = createElement("div", { class: "modal-overlay visible" }); const modal = createElement("div", { class: "modal" }); + // Ids are unique per factory, not per instance — these three trust prompts + // never stack with each other in practice. + applyDialogSemantics(modal, { labelledBy: "cert-mismatch-title" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Certificate Warning"); - const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + const title = createElement("h3", { id: "cert-mismatch-title" }, "Certificate Warning"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + // Icon-only control — the aria-label is its entire accessible name. + "aria-label": "Close", + }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); closeBtn.addEventListener("click", onReject, { signal: ac.signal }); @@ -94,7 +105,18 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun { signal: ac.signal }, ); + // Escape maps to reject because that is the fail-closed safe default + // (Disconnect) — dismissing a trust prompt must never grant trust. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) onReject(); + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + restoreFocus = focusDialog(modal); } function destroy(): void { @@ -103,6 +125,8 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun overlay.remove(); overlay = null; } + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; @@ -124,15 +148,24 @@ export interface CertFirstUseModalOptions { export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent { const { host, fingerprint, onAccept, onReject } = options; let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; const ac = new AbortController(); function mount(container: Element): void { overlay = createElement("div", { class: "modal-overlay visible" }); const modal = createElement("div", { class: "modal" }); + // Unique per factory, not per instance — the three trust prompts never + // stack with each other in practice. + applyDialogSemantics(modal, { labelledBy: "cert-first-use-title" }); + trapFocus(modal, ac.signal); const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "New Server Certificate"); - const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + const title = createElement("h3", { id: "cert-first-use-title" }, "New Server Certificate"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + "aria-label": "Close", + }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); closeBtn.addEventListener("click", onReject, { signal: ac.signal }); @@ -187,7 +220,18 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun { signal: ac.signal }, ); + // Escape rejects (Cancel) — the fail-closed default: never trust a + // certificate because the prompt was dismissed. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) onReject(); + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + restoreFocus = focusDialog(modal); } function destroy(): void { @@ -196,6 +240,8 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun overlay.remove(); overlay = null; } + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; @@ -223,15 +269,24 @@ export function createIdentityMismatchModal( ): MountableComponent { const { username, fingerprint, onAccept, onReject } = options; let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; const ac = new AbortController(); function mount(container: Element): void { overlay = createElement("div", { class: "modal-overlay visible" }); const modal = createElement("div", { class: "modal" }); + // Unique per factory, not per instance — the three trust prompts never + // stack with each other in practice. + applyDialogSemantics(modal, { labelledBy: "identity-mismatch-title" }); + trapFocus(modal, ac.signal); const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Identity Warning"); - const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + const title = createElement("h3", { id: "identity-mismatch-title" }, "Identity Warning"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + "aria-label": "Close", + }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); closeBtn.addEventListener("click", onReject, { signal: ac.signal }); @@ -287,7 +342,18 @@ export function createIdentityMismatchModal( { signal: ac.signal }, ); + // Escape rejects (Cancel) — the fail-closed default: dismissing the + // prompt must never re-pin the new identity key. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) onReject(); + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + restoreFocus = focusDialog(modal); } function destroy(): void { @@ -296,6 +362,8 @@ export function createIdentityMismatchModal( overlay.remove(); overlay = null; } + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index f8c2448b..cceaf5d6 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -22,7 +22,7 @@ import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview"; import { showUserVolumeMenu } from "./channel-sidebar/volume-menu"; import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu"; import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu"; -import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder"; +import { attachDragHandlers } from "./channel-sidebar/drag-reorder"; import { rePinPeerIdentity } from "@lib/livekitSession"; import { createIdentityMismatchModal } from "./CertMismatchModal"; import { createLogger } from "@lib/logger"; @@ -34,10 +34,11 @@ import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto" const log = createLogger("ChannelSidebar"); /** Icon, color, and tooltip for a peer's E2EE identity verification badge - * (F3 TOFU). The three states mirror the voice store's PeerVerification: + * (F3 TOFU). The states mirror the voice store's PeerVerification: * a green shield-check when the announce signature verified against the pinned - * key, a muted shield when the peer published no key (legacy), and a red - * shield-alert when the delivered key differs from the pinned one. */ + * key, a muted shield when the peer published no key (legacy), a red + * shield-alert when the delivered key differs from the pinned one, and an + * amber shield-question when the local pin store could not be read (DC-08). */ function verifyPresentation(v: PeerVerification): { icon: IconName; color: string; @@ -60,6 +61,15 @@ function verifyPresentation(v: PeerVerification): { title: "Identity key changed — click to review and re-pin", }; } + if (v.status === "unknown") { + return { + icon: "shield-question", + color: "var(--yellow, #f0b232)", + title: + "Could not check this participant's identity — key storage is unavailable, " + + "so they are blocked for E2EE until it recovers", + }; + } // "unverified" — the remaining status: peer published no identity key (legacy). return { icon: "shield", @@ -907,8 +917,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC } function destroy(): void { + // ac.abort() also releases this sidebar's hold on the shared document-level + // drag listeners (drag-reorder.ts tracks owners by signal). ac.abort(); - releaseGlobalDragListeners(channelList ?? undefined); for (const unsub of unsubscribers) { unsub(); } diff --git a/Client/tauri-client/src/components/CreateChannelModal.ts b/Client/tauri-client/src/components/CreateChannelModal.ts index d2753e54..a47b6b9a 100644 --- a/Client/tauri-client/src/components/CreateChannelModal.ts +++ b/Client/tauri-client/src/components/CreateChannelModal.ts @@ -11,6 +11,7 @@ * under every category — the server agrees (it validates the type alone). */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; @@ -44,6 +45,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo const { category, onCreate, onClose } = options; const ac = new AbortController(); let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; function mount(container: Element): void { overlay = createElement("div", { @@ -52,13 +54,17 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo }); const modal = createElement("div", { class: "modal" }); + applyDialogSemantics(modal, { labelledBy: "create-channel-title" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Create Channel"); + const title = createElement("h3", { id: "create-channel-title" }, "Create Channel"); + // Icon-only button: without a label a screen reader announces just "button". const closeBtn = createElement("button", { class: "modal-close", type: "button", + "aria-label": "Close", }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); @@ -188,8 +194,25 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo { signal: ac.signal }, ); + // Escape cancels — never creates. Document-level so it works wherever + // focus sits; guarded on the overlay still being attached because the + // listener lives until destroy() aborts it. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) { + onClose(); + } + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + // Capture where focus came from before anything inside the dialog takes + // it, so destroy() can hand it back to the opener. + restoreFocus = focusDialog(modal); + // Focus the name input nameInput.focus(); } @@ -200,6 +223,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo overlay.remove(); overlay = null; } + // Every close path (X, Cancel, backdrop, Escape) funnels through the + // caller's onClose, which calls destroy() — the single place focus + // returns to the opener. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/DeleteChannelModal.ts b/Client/tauri-client/src/components/DeleteChannelModal.ts index a125696a..297d0ba6 100644 --- a/Client/tauri-client/src/components/DeleteChannelModal.ts +++ b/Client/tauri-client/src/components/DeleteChannelModal.ts @@ -3,6 +3,7 @@ * Shows channel name and requires explicit confirmation. */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; @@ -18,6 +19,7 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo const { channelName, onConfirm, onClose } = options; const ac = new AbortController(); let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; function mount(container: Element): void { overlay = createElement("div", { @@ -26,13 +28,17 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo }); const modal = createElement("div", { class: "modal" }); + applyDialogSemantics(modal, { labelledBy: "delete-channel-title" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Delete Channel"); + const title = createElement("h3", { id: "delete-channel-title" }, "Delete Channel"); + // Icon-only button: without a label a screen reader announces just "button". const closeBtn = createElement("button", { class: "modal-close", type: "button", + "aria-label": "Close", }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); @@ -109,7 +115,24 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo { signal: ac.signal }, ); + // Escape cancels — it must never stand in for the destructive confirm. + // Document-level so it works wherever focus sits; guarded on the overlay + // still being attached because the listener lives until destroy() aborts it. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) { + onClose(); + } + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + + // Move focus in (lands on the header's close button, safely away from the + // destructive confirm) and remember the opener for destroy() to restore. + restoreFocus = focusDialog(modal); } function destroy(): void { @@ -118,6 +141,11 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo overlay.remove(); overlay = null; } + // Every close path (X, Cancel, backdrop, Escape) funnels through the + // caller's onClose, which calls destroy() — the single place focus + // returns to the opener. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/DmProfileSidebar.ts b/Client/tauri-client/src/components/DmProfileSidebar.ts index 13d490ce..c4dec688 100644 --- a/Client/tauri-client/src/components/DmProfileSidebar.ts +++ b/Client/tauri-client/src/components/DmProfileSidebar.ts @@ -31,6 +31,16 @@ export interface DmProfileData { export interface DmProfileSidebarOptions { readonly user: DmProfileData; readonly onClose: () => void; + /** + * The connected server's host, used to scope the note's localStorage key. + * User ids are per-server, so without this a note about user 5 on one + * server is shown for, and overwritten by, the unrelated user 5 on + * another — real in the multi-profile client (see profiles.ts). Optional, + * and falls back to the legacy unscoped key, so a caller that has not + * been updated to pass it yet keeps today's single-profile behavior + * exactly (including any note already saved under the old key). + */ + readonly host?: string; } export type DmProfileSidebarComponent = MountableComponent & { @@ -67,17 +77,34 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = { // Helpers // --------------------------------------------------------------------------- -function loadNote(userId: number): string { +/** The legacy unscoped key, from before per-server notes (or when the caller + * has not yet been updated to pass a host). */ +function legacyNoteKey(userId: number): string { + return NOTE_STORAGE_PREFIX + String(userId); +} + +function scopedNoteKey(userId: number, host: string): string { + return `${NOTE_STORAGE_PREFIX}${host}:${userId}`; +} + +function loadNote(userId: number, host: string): string { try { - return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? ""; + if (host !== "") { + const scoped = localStorage.getItem(scopedNoteKey(userId, host)); + if (scoped !== null) return scoped; + } + // Fall back to the legacy key so a note saved before per-server scoping + // (or while the host was unknown) is not silently lost. + return localStorage.getItem(legacyNoteKey(userId)) ?? ""; } catch { return ""; } } -function saveNote(userId: number, text: string): void { +function saveNote(userId: number, host: string, text: string): void { try { - localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text); + const key = host !== "" ? scopedNoteKey(userId, host) : legacyNoteKey(userId); + localStorage.setItem(key, text); } catch { // localStorage may be unavailable or full -- silently ignore } @@ -92,7 +119,7 @@ export function createDmProfileSidebar( ): DmProfileSidebarComponent { const ac = new AbortController(); const { signal } = ac; - const { user, onClose } = options; + const { user, onClose, host = "" } = options; let panel: HTMLDivElement | null = null; let open = false; @@ -328,12 +355,12 @@ export function createDmProfileSidebar( noteInput.style.fontSize = "13px"; noteInput.style.padding = "8px"; noteInput.style.fontFamily = "inherit"; - noteInput.value = loadNote(user.id); + noteInput.value = loadNote(user.id, host); noteInput.addEventListener( "input", () => { - saveNote(user.id, noteInput.value); + saveNote(user.id, host, noteInput.value); }, { signal }, ); diff --git a/Client/tauri-client/src/components/EditChannelModal.ts b/Client/tauri-client/src/components/EditChannelModal.ts index 6704858c..d0e3bf34 100644 --- a/Client/tauri-client/src/components/EditChannelModal.ts +++ b/Client/tauri-client/src/components/EditChannelModal.ts @@ -16,6 +16,7 @@ * shown as its own option rather than being silently rounded to a neighbour. */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; @@ -159,6 +160,7 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta const isVoice = channelType === "voice"; const ac = new AbortController(); let overlay: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; function mount(container: Element): void { overlay = createElement("div", { @@ -167,13 +169,17 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta }); const modal = createElement("div", { class: "modal" }); + applyDialogSemantics(modal, { labelledBy: "edit-channel-title" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Edit Channel"); + const title = createElement("h3", { id: "edit-channel-title" }, "Edit Channel"); + // Icon-only button: without a label a screen reader announces just "button". const closeBtn = createElement("button", { class: "modal-close", type: "button", + "aria-label": "Close", }); closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); @@ -395,7 +401,25 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta { signal: ac.signal }, ); + // Escape cancels — never saves. Document-level so it works wherever focus + // sits; guarded on the overlay still being attached because the listener + // lives until destroy() aborts it. + document.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Escape" && overlay?.isConnected === true) { + onClose(); + } + }, + { signal: ac.signal }, + ); + container.appendChild(overlay); + + // Capture where focus came from before anything inside the dialog takes + // it, so destroy() can hand it back to the opener. + restoreFocus = focusDialog(modal); + nameInput.focus(); nameInput.select(); } @@ -406,6 +430,11 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta overlay.remove(); overlay = null; } + // Every close path (X, Cancel, backdrop, Escape) funnels through the + // caller's onClose, which calls destroy() — the single place focus + // returns to the opener. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/EmojiAutocomplete.ts b/Client/tauri-client/src/components/EmojiAutocomplete.ts index 01eb336c..27a50597 100644 --- a/Client/tauri-client/src/components/EmojiAutocomplete.ts +++ b/Client/tauri-client/src/components/EmojiAutocomplete.ts @@ -50,6 +50,11 @@ export interface EmojiAutocompleteOptions { /** Called with the text to insert (`:wave:` or a unicode character). */ readonly onSelect: (insert: string) => void; readonly onClose: () => void; + /** + * Composer textarea the popup completes for; carries combobox semantics and + * aria-activedescendant while the popup is open (see inline-autocomplete). + */ + readonly comboboxInput?: HTMLElement; } /** Same shape as the shared inline-autocomplete widget. */ @@ -153,5 +158,6 @@ export function createEmojiAutocomplete( // MIN_EMOJI_QUERY, so there is nothing to prime on create. onSelect: options.onSelect, onClose: options.onClose, + comboboxInput: options.comboboxInput, }); } diff --git a/Client/tauri-client/src/components/EmojiPicker.ts b/Client/tauri-client/src/components/EmojiPicker.ts index d4fbc343..b2cc7dbb 100644 --- a/Client/tauri-client/src/components/EmojiPicker.ts +++ b/Client/tauri-client/src/components/EmojiPicker.ts @@ -2,6 +2,7 @@ // Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. import { createElement, setText, clearChildren } from "@lib/dom"; +import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y"; import { buildCustomEmojiNode } from "@components/message-list/custom-emoji"; // --------------------------------------------------------------------------- @@ -559,11 +560,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): { header.appendChild(searchInput); root.appendChild(header); - // Scrollable content area (holds category labels + grids) + // Scrollable content area (holds category labels + grids). Announced as a + // single flat listbox — the category grids are visual grouping only, and + // roving tabindex (DC-13) treats every .ep-emoji cell as one list. const scrollArea = createElement("div", { style: "overflow-y: auto; max-height: 320px;", + role: "listbox", + "aria-label": "Emoji", }); root.appendChild(scrollArea); + enableRovingNavigation(scrollArea, ".ep-emoji", signal); // Build categories with recent + custom function getAllCategories(): readonly EmojiCategory[] { @@ -596,6 +602,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): { const span = createElement("span", { class: "ep-emoji", title: emoji, + role: "option", + // Mirrors the title (the character or :shortcode: token) — e2e specs + // select cells by title, so the accessible name must never diverge. + "aria-label": emoji, }); // A `:shortcode:` entry shows its image; everything else is the character // itself. An unresolvable shortcode falls back to the text, which is what @@ -652,6 +662,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): { ); scrollArea.appendChild(empty); } + + // Every render rebuilds the cell set, so the single Tab stop must be + // re-established or filtering would leave zero tabbable cells. + setRovingTabindex(scrollArea, ".ep-emoji"); } // Initial render diff --git a/Client/tauri-client/src/components/FileUpload.ts b/Client/tauri-client/src/components/FileUpload.ts deleted file mode 100644 index ecd49660..00000000 --- a/Client/tauri-client/src/components/FileUpload.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Step 8.59 — File upload component with drag-and-drop, preview, and progress. -// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. - -import { createElement, setText, appendChildren } from "@lib/dom"; -import { createIcon } from "@lib/icons"; -import type { MountableComponent } from "@lib/safe-render"; - -/** Default allowed MIME types for file uploads. */ -const DEFAULT_ALLOWED_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/avif", - "video/mp4", - "video/webm", - "audio/mpeg", - "audio/ogg", - "audio/wav", - "application/pdf", - "text/plain", -]; - -export interface FileUploadOptions { - readonly onUpload: (file: File) => Promise<void>; - readonly maxSizeMb?: number; - readonly allowedMimeTypes?: readonly string[]; -} - -const DEFAULT_MAX_SIZE_MB = 10; - -export type FileUploadComponent = MountableComponent & { openPicker(): void }; - -export function createFileUpload(options: FileUploadOptions): FileUploadComponent { - const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024; - const ac = new AbortController(); - const signal = ac.signal; - - let root: HTMLDivElement | null = null; - let dropzone: HTMLDivElement; - let fileInput: HTMLInputElement; - let preview: HTMLDivElement; - let thumb: HTMLImageElement; - let nameSpan: HTMLSpanElement; - let sizeSpan: HTMLSpanElement; - let progressBar: HTMLDivElement; - let cancelBtn: HTMLButtonElement; - let errorDiv: HTMLDivElement; - let uploadAbort: AbortController | null = null; - - // oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability - function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } - - function showError(message: string): void { - setText(errorDiv, message); - errorDiv.classList.remove("file-upload__error--hidden"); - preview.classList.add("file-upload__preview--hidden"); - } - - function resetPreview(): void { - preview.classList.add("file-upload__preview--hidden"); - thumb.src = ""; - thumb.style.display = "none"; - setText(nameSpan, ""); - setText(sizeSpan, ""); - progressBar.style.width = "0%"; - uploadAbort = null; - errorDiv.classList.add("file-upload__error--hidden"); - } - - function showPreview(file: File): void { - resetPreview(); - setText(nameSpan, file.name); - setText(sizeSpan, formatSize(file.size)); - if (file.type.startsWith("image/")) { - const url = URL.createObjectURL(file); - thumb.src = url; - thumb.style.display = "block"; - thumb.addEventListener("load", () => URL.revokeObjectURL(url)); - } - preview.classList.remove("file-upload__preview--hidden"); - } - - async function handleFile(file: File): Promise<void> { - errorDiv.classList.add("file-upload__error--hidden"); - const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES; - if (file.type && !allowed.includes(file.type)) { - showError(`File type "${file.type}" is not allowed.`); - return; - } - if (file.size > maxBytes) { - showError( - `File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`, - ); - return; - } - showPreview(file); - uploadAbort = new AbortController(); - try { - progressBar.style.width = "50%"; - await options.onUpload(file); - progressBar.style.width = "100%"; - setTimeout(() => resetPreview(), 1500); - } catch (err) { - if (uploadAbort?.signal.aborted) return; - showError(err instanceof Error ? err.message : "Upload failed"); - resetPreview(); - } - } - - function buildDom(): void { - root = createElement("div", { class: "file-upload" }); - - dropzone = createElement("div", { - class: "file-upload__dropzone file-upload__dropzone--hidden", - }); - appendChildren( - dropzone, - createElement("span", { class: "file-upload__droptext" }, "Drop files here"), - ); - - const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES; - fileInput = createElement("input", { - class: "file-upload__input", - type: "file", - accept: allowed.join(","), - }); - fileInput.style.display = "none"; - - preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" }); - thumb = createElement("img", { class: "file-upload__thumb" }); - thumb.style.display = "none"; - thumb.alt = ""; - nameSpan = createElement("span", { class: "file-upload__name" }); - sizeSpan = createElement("span", { class: "file-upload__size" }); - const progressContainer = createElement("div", { class: "file-upload__progress" }); - progressBar = createElement("div", { class: "file-upload__progress-bar" }); - progressBar.style.width = "0%"; - appendChildren(progressContainer, progressBar); - cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }); - cancelBtn.appendChild(createIcon("x", 14)); - appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn); - - errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" }); - appendChildren(root, dropzone, fileInput, preview, errorDiv); - } - - function attachListeners(): void { - fileInput.addEventListener( - "change", - () => { - const file = fileInput.files?.[0]; - if (file) { - void handleFile(file); - fileInput.value = ""; - } - }, - { signal }, - ); - - cancelBtn.addEventListener( - "click", - () => { - if (uploadAbort !== null) uploadAbort.abort(); - resetPreview(); - }, - { signal }, - ); - - let dragCounter = 0; - root!.addEventListener( - "dragenter", - (e) => { - e.preventDefault(); - dragCounter++; - dropzone.classList.remove("file-upload__dropzone--hidden"); - }, - { signal }, - ); - - root!.addEventListener( - "dragleave", - (e) => { - e.preventDefault(); - dragCounter--; - if (dragCounter <= 0) { - dragCounter = 0; - dropzone.classList.add("file-upload__dropzone--hidden"); - } - }, - { signal }, - ); - - root!.addEventListener("dragover", (e) => e.preventDefault(), { signal }); - - root!.addEventListener( - "drop", - (e) => { - e.preventDefault(); - dragCounter = 0; - dropzone.classList.add("file-upload__dropzone--hidden"); - const file = e.dataTransfer?.files[0]; - if (file) void handleFile(file); - }, - { signal }, - ); - } - - function mount(container: Element): void { - buildDom(); - attachListeners(); - container.appendChild(root!); - } - - function destroy(): void { - ac.abort(); - if (uploadAbort !== null) uploadAbort.abort(); - root?.remove(); - root = null; - } - - function openPicker(): void { - fileInput.click(); - } - - return { mount, destroy, openPicker }; -} diff --git a/Client/tauri-client/src/components/GifPicker.ts b/Client/tauri-client/src/components/GifPicker.ts index 6420cb93..5415a704 100644 --- a/Client/tauri-client/src/components/GifPicker.ts +++ b/Client/tauri-client/src/components/GifPicker.ts @@ -3,6 +3,7 @@ // innerHTML with user content. import { createElement, setText, clearChildren } from "@lib/dom"; +import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y"; import { ApiClientError } from "@lib/api"; import { searchGifs, getTrendingGifs } from "@lib/gifProvider"; import type { GifApi, GifResult } from "@lib/gifProvider"; @@ -67,9 +68,15 @@ export function createGifPicker(options: GifPickerOptions): { root.appendChild(header); - // Grid area (scrollable) - const gridArea = createElement("div", { class: "gp-grid-area" }); + // Grid area (scrollable). Announced as a flat listbox of GIF options with + // roving tabindex (DC-13); the inner .gp-grid is layout only. + const gridArea = createElement("div", { + class: "gp-grid-area", + role: "listbox", + "aria-label": "GIFs", + }); root.appendChild(gridArea); + enableRovingNavigation(gridArea, ".gp-item", signal); // Loading indicator const loadingEl = createElement("div", { class: "gp-loading" }); @@ -92,7 +99,13 @@ export function createGifPicker(options: GifPickerOptions): { const grid = createElement("div", { class: "gp-grid" }); for (const gif of gifs) { - const item = createElement("div", { class: "gp-item" }); + const item = createElement("div", { + class: "gp-item", + role: "option", + // Same fallback as the img alt below — an untitled GIF still needs a + // pronounceable accessible name. + "aria-label": gif.title || "GIF", + }); const img = createElement("img", { class: "gp-img", src: gif.url, @@ -114,6 +127,9 @@ export function createGifPicker(options: GifPickerOptions): { } gridArea.appendChild(grid); + + // Each render replaces the cell set, so re-establish the single Tab stop. + setRovingTabindex(gridArea, ".gp-item"); } function showLoading(): void { diff --git a/Client/tauri-client/src/components/InviteManager.ts b/Client/tauri-client/src/components/InviteManager.ts index 94c83d7d..48726188 100644 --- a/Client/tauri-client/src/components/InviteManager.ts +++ b/Client/tauri-client/src/components/InviteManager.ts @@ -3,6 +3,7 @@ * Create, copy, and revoke invite codes. */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, appendChildren, clearChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; @@ -56,6 +57,7 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom let root: HTMLDivElement | null = null; let listEl: HTMLDivElement | null = null; let emptyEl: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; let invites: readonly InviteItem[] = options.invites; function renderList(): void { @@ -161,11 +163,14 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom const modal = createElement("div", { class: "modal", }); + applyDialogSemantics(modal, { labelledBy: "invite-manager-title" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Server Invites"); - const closeBtn = createElement("button", { class: "modal-close" }); + const title = createElement("h3", { id: "invite-manager-title" }, "Server Invites"); + // Icon-only button: without a label a screen reader announces just "button". + const closeBtn = createElement("button", { class: "modal-close", "aria-label": "Close" }); closeBtn.appendChild(createIcon("x", 14)); closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal }); appendChildren(header, title, closeBtn); @@ -236,6 +241,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom renderList(); container.appendChild(root); + + // Capture where focus came from before anything inside the dialog takes + // it, so destroy() can hand it back to the opener. + restoreFocus = focusDialog(modal); } function destroy(): void { @@ -246,6 +255,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom } listEl = null; emptyEl = null; + // Every close path (X, backdrop, Escape) funnels through the caller's + // onClose, which calls destroy() — the single place focus returns. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/MentionAutocomplete.ts b/Client/tauri-client/src/components/MentionAutocomplete.ts index 854668fd..99c6963a 100644 --- a/Client/tauri-client/src/components/MentionAutocomplete.ts +++ b/Client/tauri-client/src/components/MentionAutocomplete.ts @@ -32,6 +32,11 @@ export interface MentionAutocompleteOptions { /** Called with the token to insert (without the leading "@"). */ readonly onSelect: (token: string) => void; readonly onClose: () => void; + /** + * Composer textarea the popup completes for; carries combobox semantics and + * aria-activedescendant while the popup is open (see inline-autocomplete). + */ + readonly comboboxInput?: HTMLElement; } /** Same shape as the shared inline-autocomplete widget. */ @@ -121,5 +126,6 @@ export function createMentionAutocomplete( primeOnCreate: true, onSelect: options.onSelect, onClose: options.onClose, + comboboxInput: options.comboboxInput, }); } diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index ae032d29..c5d9b691 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -90,7 +90,16 @@ export function wrapWithMarker( const len = marker.length; // Already wrapped — pressing the shortcut again takes the markers back off. - if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) { + // The interior must not itself contain the marker: otherwise a selection + // that merely starts and ends with it (e.g. multiple already-wrapped spans, + // or a longer marker like "**" matching the outer edge of "*x*") would be + // mistaken for a single wrapped span and have its interior markers stripped. + if ( + selected.length > 2 * len && + selected.startsWith(marker) && + selected.endsWith(marker) && + !selected.slice(len, selected.length - len).includes(marker) + ) { const inner = selected.slice(len, selected.length - len); return { value: value.slice(0, start) + inner + value.slice(end), @@ -128,6 +137,22 @@ const ALLOWED_TYPES = [ "application/json", ]; +/** + * Keys that move the caret without an open autocomplete popup claiming them, + * so the popup has to be resynced against the new caret on keyup. The popup's + * own keys are deliberately absent: it consumes ArrowUp/ArrowDown/Enter/Tab + * (so the caret does not move) and Escape closes it, and resyncing after any + * of those would reset the highlighted row or reopen what Escape dismissed. + */ +const CARET_MOVE_KEYS: ReadonlySet<string> = new Set([ + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", +]); + /** Disable the GIF button and say why, instead of silently doing nothing. */ function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void { gifBtn.setAttribute("disabled", "true"); @@ -198,7 +223,13 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo /** Replace the token under the caret with "@token ". */ function insertMention(token: string): void { - if (textarea === null || mentionStart < 0) { + // The popup can outlive the token it was opened over: a caret move the + // composer never observed (Ctrl+A, a programmatic selection) leaves + // mentionStart pointing at an offset the caret no longer follows, and + // splicing there garbles the draft instead of completing it. Re-derive + // the token and only commit while it still starts where the popup thinks. + const active = activeMentionToken(); + if (textarea === null || mentionStart < 0 || active === null || active.start !== mentionStart) { closeMentionPopup(); return; } @@ -242,7 +273,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo /** Replace the `:token` under the caret with the chosen emoji, plus a space. */ function insertEmoji(insert: string): void { - if (textarea === null || emojiStart < 0) { + // Same staleness guard as insertMention: never splice at an anchor the + // caret has since moved away from. + const active = activeEmojiToken(); + if (textarea === null || emojiStart < 0 || active === null || active.start !== emojiStart) { closeEmojiPopup(); return; } @@ -270,6 +304,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo emojiPopup = createEmojiAutocomplete({ onSelect: insertEmoji, onClose: closeEmojiPopup, + // The popup manages combobox/aria-activedescendant state on the + // textarea for as long as it is open. + comboboxInput: textarea ?? undefined, }); root?.appendChild(emojiPopup.element); } @@ -306,6 +343,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo mentionPopup = createMentionAutocomplete({ onSelect: insertMention, onClose: closeMentionPopup, + // The popup manages combobox/aria-activedescendant state on the + // textarea for as long as it is open. + comboboxInput: textarea ?? undefined, }); root?.appendChild(mentionPopup.element); } @@ -452,8 +492,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo /** Unique counter for preview items (before upload completes and we have a server ID). */ let previewCounter = 0; - function removePreviewItem(tempId: string): void { - const idx = pendingAttachments.findIndex((a) => a.id === tempId); + function removePreviewItem(el: HTMLDivElement): void { + const idx = pendingAttachments.findIndex((a) => a.previewEl === el); const att = idx !== -1 ? pendingAttachments[idx] : undefined; if (att !== undefined) { const img = att.previewEl.querySelector("img"); @@ -540,7 +580,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo "click", (e) => { e.stopPropagation(); - removePreviewItem(tempId); + removePreviewItem(item); }, { signal }, ); @@ -566,7 +606,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo } } catch (err) { // Upload failed — remove preview and show error - removePreviewItem(tempId); + removePreviewItem(item); const errMsg = err instanceof Error ? err.message : "Upload failed"; showUploadError(`Upload failed: ${errMsg}`); } finally { @@ -643,7 +683,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo const fileInput = createElement("input", { type: "file", style: "display: none;", - accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z", + accept: "image/*,video/*,audio/*,.pdf,.txt,.zip", }); fileInput.addEventListener( "change", @@ -764,8 +804,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo { signal }, ); - // Caret moves that aren't typing (click, blur) also decide the popup's fate. + // Caret moves that aren't typing (click, arrow/Home/End keys, blur) also + // decide the popup's fate — without this, completing a mention/emoji + // after moving the caret away with the keyboard splices at a stale offset. textarea.addEventListener("click", syncAutocomplete, { signal }); + textarea.addEventListener( + "keyup", + (e: KeyboardEvent) => { + if (CARET_MOVE_KEYS.has(e.key)) syncAutocomplete(); + }, + { signal }, + ); textarea.addEventListener( "blur", () => { @@ -881,9 +930,20 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo markGifUnavailable(gifBtn, reason); }, onSelect: (gifUrl: string) => { - if (textarea !== null) { - textarea.value = gifUrl; - handleSend(); + // Send the GIF directly instead of routing it through the textarea + // (handleSend's read of textarea.value): that overwrote — and + // discarded — whatever draft the user had typed, and on slow + // mode / mid-upload / debounced sends left the raw GIF URL sitting + // in the composer instead of the draft. Guarded by the same + // disabledReason/debounce checks as a normal send; an in-progress + // edit and any typed draft are left untouched. + if (disabledReason === null) { + const now = Date.now(); + if (now - lastSendTime >= SEND_DEBOUNCE_MS) { + lastSendTime = now; + options.onSend(gifUrl, state.replyTo?.messageId ?? null, []); + clearReply(); + } } closeGifPicker(); }, diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 33aa3fe7..e3acc7d2 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -36,7 +36,9 @@ export interface MessageListOptions { readonly channelName: string; readonly channelType?: string; readonly currentUserId: number; - readonly onScrollTop: () => void; + /** May return a promise (e.g. the underlying fetch); MessageList clears its + * loadingOlder latch once it settles, success or failure. */ + readonly onScrollTop: () => void | Promise<void>; readonly onReplyClick: (messageId: number) => void; readonly onEditClick: (messageId: number) => void; readonly onDeleteClick: (messageId: number) => void; @@ -266,6 +268,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo */ const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId); + /** + * Message id the NEW divider is anchored to, once one has been picked. + * `firstUnreadIndex` returns a count-from-the-end offset, which drifts + * whenever the loaded window grows (new messages arrive) between one full + * rebuild and the next — the exact thing unreadOnOpen's doc comment above + * promises won't happen. Latching onto the message id the first valid index + * pointed at keeps the divider glued to that message for the rest of the + * visit regardless of how the window grows around it. + */ + let newDividerAnchorId: number | null = null; + + /** + * Resolve the NEW divider's position for this rebuild. Prefers the latched + * anchor id (stable across window growth); falls back to the count formula + * only until an anchor exists, then latches it — skipping id 0 (an + * unconfirmed optimistic row) since that id is not unique across pending + * sends and would anchor to the wrong message once reconciled. + */ + function resolveNewDividerIndex(messages: readonly Message[]): number { + if (newDividerAnchorId !== null) { + return messages.findIndex((m) => m.id === newDividerAnchorId); + } + const idx = firstUnreadIndex(messages, unreadOnOpen); + const anchor = idx !== -1 ? messages[idx] : undefined; + if (anchor !== undefined && anchor.id !== 0) { + newDividerAnchorId = anchor.id; + } + return idx; + } + // --------------------------------------------------------------------------- // Height estimation (Fenwick tree backed) // --------------------------------------------------------------------------- @@ -512,12 +544,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo function rebuildItems(): void { allMessages = getChannelMessages(options.channelId); - virtualItems = buildVirtualItems( - allMessages, - null, - null, - firstUnreadIndex(allMessages, unreadOnOpen), - ); + virtualItems = buildVirtualItems(allMessages, null, null, resolveNewDividerIndex(allMessages)); // Build Fenwick tree initialized with smart estimates / cached heights tree = new FenwickTree(virtualItems.length); @@ -710,7 +737,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo hasMoreMessages(options.channelId) ) { loadingOlder = true; - options.onScrollTop(); + // A failed fetch never changes the message count, so the subscriber + // below (which only reacts to a count change) would leave loadingOlder + // latched forever. Clear it once the load settles either way — the + // subscriber's reset still applies to the success path but is now just + // belt-and-braces. + void Promise.resolve(options.onScrollTop()).finally(() => { + loadingOlder = false; + }); } // Update floating scroll-to-bottom button visibility diff --git a/Client/tauri-client/src/components/PinnedMessages.ts b/Client/tauri-client/src/components/PinnedMessages.ts index 6aea5926..cc785c21 100644 --- a/Client/tauri-client/src/components/PinnedMessages.ts +++ b/Client/tauri-client/src/components/PinnedMessages.ts @@ -61,11 +61,17 @@ function renderPinnedItem( // Hover actions const actions = createElement("div", { class: "pinned-msg__actions" }); - const jumpBtn = createElement("button", { title: "Jump to message" }); + // Icon-only buttons: title= only tooltips for mouse users, so mirror it as + // an aria-label for screen readers. + const jumpBtn = createElement("button", { + title: "Jump to message", + "aria-label": "Jump to message", + }); jumpBtn.appendChild(createIcon("external-link", 14)); const unpinBtn = createElement("button", { class: "pinned-msg__unpin", title: "Unpin message", + "aria-label": "Unpin message", }); unpinBtn.appendChild(createIcon("x", 14)); @@ -94,7 +100,13 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC let root: HTMLDivElement | null = null; function mount(container: Element): void { - root = createElement("div", { class: "pinned-panel" }); + // A side panel, not a modal: complementary landmark (no aria-modal, no + // focus trap), matching DmProfileSidebar. + root = createElement("div", { + class: "pinned-panel", + role: "complementary", + "aria-label": "Pinned messages", + }); // Header const header = createElement("div", { class: "pinned-panel__header" }); @@ -106,7 +118,10 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC const count = createElement("span", { class: "pinned-panel__count" }); count.textContent = String(options.pinnedMessages.length); - const closeBtn = createElement("button", { class: "pinned-panel__close" }); + const closeBtn = createElement("button", { + class: "pinned-panel__close", + "aria-label": "Close pinned messages", + }); closeBtn.appendChild(createIcon("x", 16)); closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal }); diff --git a/Client/tauri-client/src/components/QuickSwitchOverlay.ts b/Client/tauri-client/src/components/QuickSwitchOverlay.ts index bf7e0303..1bd34ced 100644 --- a/Client/tauri-client/src/components/QuickSwitchOverlay.ts +++ b/Client/tauri-client/src/components/QuickSwitchOverlay.ts @@ -4,6 +4,7 @@ * Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, appendChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; @@ -31,6 +32,7 @@ export interface QuickSwitchOverlayOptions { export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent { const ac = new AbortController(); let root: HTMLDivElement | null = null; + let restoreFocus: (() => void) | null = null; function mount(container: Element): void { root = createElement("div", { @@ -48,6 +50,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo ); const modal = createElement("div", { class: "quick-switch-modal" }); + applyDialogSemantics(modal, { label: "Switch server" }); + trapFocus(modal, ac.signal); // Header const header = createElement("div", { class: "quick-switch-header" }); @@ -64,11 +68,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo for (const profile of options.profiles) { const isCurrent = profile.host === options.currentHost; - const item = createElement("div", { + const attrs: Record<string, string> = { class: `quick-switch-item${isCurrent ? " current" : ""}`, "data-testid": "server-item", "data-host": profile.host, - }); + }; + // Only actionable rows get button semantics — the connected row has no + // click handler, and a "button" that does nothing lies to screen readers. + if (!isCurrent) { + attrs["role"] = "button"; + attrs["tabindex"] = "0"; + } + const item = createElement("div", attrs); const icon = createElement("div", { class: "quick-switch-icon" }); setText(icon, profile.name.charAt(0).toUpperCase()); @@ -94,6 +105,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo }, { signal: ac.signal }, ); + // Divs get no native key activation; Enter/Space mirrors the click + // so the row honors the button role it advertises. + item.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + options.onSwitch(profile.host, profile.name); + } + }, + { signal: ac.signal }, + ); } list.appendChild(item); @@ -103,6 +126,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo const addItem = createElement("div", { class: "quick-switch-item add-new", "data-testid": "add-server-btn", + role: "button", + tabindex: "0", }); const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+"); const addInfo = createElement("div", { class: "quick-switch-info" }); @@ -115,6 +140,16 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo appendChildren(addInfo, addName, addHost); appendChildren(addItem, addIcon, addInfo); addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal }); + addItem.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + options.onAddServer(); + } + }, + { signal: ac.signal }, + ); list.appendChild(addItem); // Footer @@ -124,6 +159,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo root.appendChild(modal); container.appendChild(root); + // Move focus onto the first actionable row (or the modal itself) and + // remember the opener — the UserBar switch button — for destroy(). + restoreFocus = focusDialog(modal); + // Escape key closes overlay document.addEventListener( "keydown", @@ -140,6 +179,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo root.remove(); root = null; } + // Restore after removal so focus cannot land on a node inside the + // just-detached overlay. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/QuickSwitcher.ts b/Client/tauri-client/src/components/QuickSwitcher.ts index a57e8ff3..846b47e6 100644 --- a/Client/tauri-client/src/components/QuickSwitcher.ts +++ b/Client/tauri-client/src/components/QuickSwitcher.ts @@ -1,6 +1,7 @@ // Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation. // Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, setText, appendChildren, clearChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import { channelsStore } from "@stores/channels.store"; @@ -22,6 +23,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom let activeIndex = 0; let filteredChannels: readonly Channel[] = []; let unsubscribe: (() => void) | null = null; + let restoreFocus: (() => void) | null = null; function getChannelIcon(ch: Channel): SVGSVGElement { return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14); @@ -29,7 +31,11 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom function getFilteredChannels(query: string): readonly Channel[] { const state = channelsStore.getState(); - const all = Array.from(state.channels.values()); + // DM rows are synthesized into channelsStore once opened, but they have + // their own sidebar path (full clearDmUnread/setSidebarMode handling) — + // listing them here too would select via a bare setActiveChannel and + // leave their unread/mention badge lit forever. + const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm"); const sorted = [...all].toSorted((a, b) => a.position - b.position); if (query.length === 0) return sorted; @@ -51,6 +57,12 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom ? "quick-switcher__item quick-switcher__item--active" : "quick-switcher__item", "data-channelid": String(ch.id), + // Combobox option wiring: the id feeds aria-activedescendant so a + // screen reader tracks the roving --active highlight without the + // input ever losing DOM focus. + id: `qs-option-${i}`, + role: "option", + "aria-selected": isActive ? "true" : "false", }); const icon = createElement("span", { class: "quick-switcher__icon" }); @@ -79,6 +91,16 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom resultsDiv.appendChild(item); } + + // Re-point aria-activedescendant on every render — arrow keys, filtering + // and store refreshes all funnel through here, so it can never go stale. + // An empty result set clears it; pointing at a missing id is worse than + // pointing at nothing. + if (filteredChannels.length > 0) { + input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`); + } else { + input.removeAttribute("aria-activedescendant"); + } } function handleInput(): void { @@ -154,21 +176,39 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom // Modal container const modal = createElement("div", { class: "quick-switcher" }); + applyDialogSemantics(modal, { label: "Quick switcher" }); + trapFocus(modal, signal); - // Search input + // Search input — combobox over the results listbox: the input keeps DOM + // focus while aria-activedescendant (set in renderResults) names the row + // the arrow keys have highlighted. The list is always rendered, so + // aria-expanded is statically true. input = createElement("input", { class: "quick-switcher__input", type: "text", placeholder: "Where do you want to go?", + role: "combobox", + "aria-expanded": "true", + "aria-autocomplete": "list", + "aria-controls": "quick-switcher-results", }); // Results list - resultsDiv = createElement("div", { class: "quick-switcher__results" }); + resultsDiv = createElement("div", { + class: "quick-switcher__results", + id: "quick-switcher-results", + role: "listbox", + }); appendChildren(modal, input, resultsDiv); root.appendChild(modal); container.appendChild(root); + // Capture the opener before anything inside grabs focus — Ctrl+K comes + // from the composer, and a keyboard user needs destroy() to land them + // back there, not at the top of the document. + restoreFocus = focusDialog(modal); + // Initial render filteredChannels = getFilteredChannels(""); renderResults(); @@ -194,6 +234,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom } root?.remove(); root = null; + // Restore after the overlay is gone, so focus cannot land on a node the + // removal is about to detach. + restoreFocus?.(); + restoreFocus = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/ServerStrip.ts b/Client/tauri-client/src/components/ServerStrip.ts deleted file mode 100644 index 4118d478..00000000 --- a/Client/tauri-client/src/components/ServerStrip.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * ServerStrip component — vertical strip on the far left showing server icons. - * Single-server for now: Home button, separator, add server button. - */ - -import { createElement, appendChildren } from "@lib/dom"; -import type { MountableComponent } from "@lib/safe-render"; - -export function createServerStrip(): MountableComponent { - const ac = new AbortController(); - let root: HTMLDivElement | null = null; - - function mount(container: Element): void { - root = createElement("div", { class: "server-strip", "data-testid": "server-strip" }); - - const homeIcon = createElement( - "div", - { class: "server-icon active", style: "background: var(--accent)" }, - "O", - ); - - const separator = createElement("div", { class: "server-separator" }); - - const addIcon = createElement("div", { class: "server-icon add" }, "+"); - - // Add server button click — placeholder for future multi-server support - addIcon.addEventListener( - "click", - () => { - // No-op for single-server mode - }, - { signal: ac.signal }, - ); - - appendChildren(root, homeIcon, separator, addIcon); - container.appendChild(root); - } - - function destroy(): void { - ac.abort(); - if (root !== null) { - root.remove(); - root = null; - } - } - - return { mount, destroy }; -} diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index f5d3645f..0c5aa2d5 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -4,6 +4,7 @@ * Subscribes to uiStore for settingsOpen state. */ +import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y"; import { createElement, appendChildren, clearChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { IconName } from "@lib/icons"; @@ -73,6 +74,11 @@ const TAB_ICONS: Record<TabName, IconName> = { Logs: "scroll-text", }; +/** Stable DOM id for a tab button (aria-labelledby target), e.g. "settings-tab-text-images". */ +function tabId(name: TabName): string { + return `settings-tab-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -83,11 +89,14 @@ export function createSettingsOverlay( const ac = new AbortController(); const authenticated = options.isAuthenticated !== false; let root: HTMLDivElement | null = null; + let panel: HTMLDivElement | null = null; let contentArea: HTMLDivElement | null = null; let pageTitle: HTMLHeadingElement | null = null; let activeTab: TabName = authenticated ? "Account" : "Appearance"; /** False once the active tab's content has been torn down by `hide()`. */ let contentLive = false; + /** Puts focus back on whatever opened the panel; null while closed. */ + let restoreFocus: (() => void) | null = null; const tabButtons = new Map<TabName, HTMLButtonElement>(); let unsubUi: (() => void) | null = null; let unsubAuth: (() => void) | null = null; @@ -139,16 +148,25 @@ export function createSettingsOverlay( for (const [name, btn] of tabButtons) { btn.classList.toggle("active", name === tab); btn.setAttribute("aria-selected", name === tab ? "true" : "false"); + // Roving tabindex: only the active tab sits in the page Tab order. + btn.setAttribute("tabindex", name === tab ? "0" : "-1"); } + contentArea?.setAttribute("aria-labelledby", tabId(tab)); renderActiveTab(); } function show(): void { + const wasOpen = root?.classList.contains("open") ?? false; root?.classList.add("open"); // Closing tore down the live parts of the active tab (mic meter, camera // preview, log listener). Rebuild it so a reopened panel shows live state // instead of a frozen snapshot — and so every tab re-reads current prefs. if (!contentLive) renderActiveTab(); + // Move focus in only on the closed→open transition — a repeated show() + // would otherwise capture an element inside the panel as the "opener". + if (!wasOpen && panel !== null) { + restoreFocus = focusDialog(panel); + } } function hide(): void { @@ -156,6 +174,9 @@ export function createSettingsOverlay( // Stop camera preview, mic meter, and the log listener when the overlay closes cleanupActiveTab(); contentLive = false; + // Hand focus back to whatever opened the panel. + restoreFocus?.(); + restoreFocus = null; } // ---- MountableComponent --------------------------------------------------- @@ -163,8 +184,42 @@ export function createSettingsOverlay( function mount(container: Element): void { root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" }); - // Sidebar - const sidebar = createElement("div", { class: "settings-sidebar" }); + // Sidebar. It doubles as the tablist: the profile block, category headings + // ("User Settings" / "App Settings") and the Log Out button also live in + // here, and a tablist should own only tabs — but moving them out would + // change the structure the e2e selectors pin down, so we accept that the + // non-tab children are presentational noise inside the tablist (DC-13). + const sidebar = createElement("div", { + class: "settings-sidebar", + role: "tablist", + "aria-orientation": "vertical", + "aria-label": "Settings sections", + }); + + // Arrow-key navigation between tabs, activate-on-focus (the simpler + // conformant flavor of the WAI-ARIA tabs pattern). Vertical list, so only + // Up/Down move; Home/End jump to the edges; both directions wrap. + sidebar.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") { + return; + } + const order = [...tabButtons.keys()]; + const current = order.findIndex((name) => tabButtons.get(name) === e.target); + if (current === -1) return; // e.g. the Log Out button — not a tab + e.preventDefault(); + let next: number; + if (e.key === "ArrowDown") next = (current + 1) % order.length; + else if (e.key === "ArrowUp") next = (current - 1 + order.length) % order.length; + else if (e.key === "Home") next = 0; + else next = order.length - 1; + const name = order[next]!; + setActiveTab(name); + tabButtons.get(name)?.focus(); + }, + { signal: ac.signal }, + ); // User profile section at top of sidebar const user = authStore.getState().user; @@ -213,8 +268,10 @@ export function createSettingsOverlay( const accountBtn = createElement("button", { class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`, + id: tabId("Account"), role: "tab", "aria-selected": activeTab === "Account" ? "true" : "false", + tabindex: activeTab === "Account" ? "0" : "-1", }); accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18)); accountBtn.appendChild(document.createTextNode("Account")); @@ -240,8 +297,10 @@ export function createSettingsOverlay( for (const name of appTabs) { const btn = createElement("button", { class: `settings-nav-item${name === activeTab ? " active" : ""}`, + id: tabId(name), role: "tab", "aria-selected": name === activeTab ? "true" : "false", + tabindex: name === activeTab ? "0" : "-1", }); btn.prepend(createIcon(TAB_ICONS[name], 18)); btn.appendChild(document.createTextNode(name)); @@ -263,8 +322,12 @@ export function createSettingsOverlay( // Page title (h1) at top of content area — created here, inserted in renderActiveTab pageTitle = createElement("h1", {}, activeTab); - // Content - contentArea = createElement("div", { class: "settings-content" }); + // Content — the single tabpanel, renamed per switch via aria-labelledby + contentArea = createElement("div", { + class: "settings-content", + role: "tabpanel", + "aria-labelledby": tabId(activeTab), + }); // Close button wrapped with ESC label const closeWrap = createElement("div", { class: "settings-close-wrap" }); @@ -292,7 +355,11 @@ export function createSettingsOverlay( ); // Inner panel (Discord-style centered card) - const panel = createElement("div", { class: "settings-panel" }); + panel = createElement("div", { class: "settings-panel" }); + applyDialogSemantics(panel, { label: "Settings" }); + // Arming the trap while hidden is safe: Tab can't land inside a + // display:none panel, so the handler only fires while the overlay is open. + trapFocus(panel, ac.signal); appendChildren(panel, sidebar, contentArea, closeWrap); // Click backdrop (outside panel) to close @@ -343,10 +410,14 @@ export function createSettingsOverlay( logsTab.cleanup(); voiceTab.cleanup(); tabButtons.clear(); + // Tearing down while open still hands focus back to the opener. + restoreFocus?.(); + restoreFocus = null; if (root !== null) { root.remove(); root = null; } + panel = null; contentArea = null; pageTitle = null; } diff --git a/Client/tauri-client/src/components/Toast.ts b/Client/tauri-client/src/components/Toast.ts index baab3875..50905722 100644 --- a/Client/tauri-client/src/components/Toast.ts +++ b/Client/tauri-client/src/components/Toast.ts @@ -100,7 +100,16 @@ export function createToastContainer(): ToastContainer { } function mount(container: Element): void { - root = createElement("div", { class: "toast-container", "data-testid": "toast-container" }); + // One polite live region for all toasts (DC-13): screen readers announce + // each toast as it is appended without interrupting current speech. + // aria-atomic="false" so only the newly added toast is read, not the stack. + root = createElement("div", { + class: "toast-container", + "data-testid": "toast-container", + role: "status", + "aria-live": "polite", + "aria-atomic": "false", + }); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/TypingIndicator.ts b/Client/tauri-client/src/components/TypingIndicator.ts index 0e516b82..395ea7fd 100644 --- a/Client/tauri-client/src/components/TypingIndicator.ts +++ b/Client/tauri-client/src/components/TypingIndicator.ts @@ -56,7 +56,13 @@ export function createTypingIndicator(options: TypingIndicatorOptions): Mountabl } function mount(container: Element): void { - root = createElement("div", { class: "typing-bar" }); + // Polite live region (DC-13): "X is typing" changes are announced without + // interrupting whatever the screen reader is currently speaking. + root = createElement("div", { + class: "typing-bar", + role: "status", + "aria-live": "polite", + }); updateFromState(); diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts index 00423a34..3f577b74 100644 --- a/Client/tauri-client/src/components/UserProfilePopup.ts +++ b/Client/tauri-client/src/components/UserProfilePopup.ts @@ -3,8 +3,9 @@ * in the chat or member list. Shows avatar, username, role badge, status dot, * about section, join date, and Message/Call action buttons. * - * Position: anchored to click point, flips if <100px from viewport edge. - * Animation: fade+scale 100ms. + * Position: anchored to the click point, flipped to the other side and clamped + * against the measured card height so it always lands fully on screen. + * Animation: fade+scale, defined in CSS so reduced-motion can drop it. * Close: outside click or Escape. * A11y: role="dialog", aria-label, focus trap, return focus on close. */ @@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & { // --------------------------------------------------------------------------- const POPUP_WIDTH = 300; -const EDGE_THRESHOLD = 100; -const ANIMATION_DURATION_MS = 100; +/** Keeps the card clear of the window edges on both axes. */ +const VIEWPORT_MARGIN = 8; +/** Breathing room between the click point and the card. */ +const ANCHOR_GAP = 8; const STATUS_COLORS: Record<UserStatus, string> = { online: "#3ba55d", @@ -110,28 +113,37 @@ export function createUserProfilePopup( } } - function computePosition(anchorX: number, anchorY: number): { left: number; top: number } { + /** + * Place the card beside the anchor, flipping and clamping so it always lands + * fully on screen — Discord opens its popout away from whichever edge the + * clicked row is nearest. + * + * The height is measured rather than assumed. The previous version guessed + * 300px and only clamped the top edge, so a member clicked low in the list + * opened a card that ran off the bottom of the window. + */ + function position(el: HTMLElement, anchorX: number, anchorY: number): void { const vw = window.innerWidth; const vh = window.innerHeight; + const height = el.offsetHeight; - let left = anchorX; + // Prefer the right of the anchor and flip left when there is no room. The + // member list sits against the right edge, so flipping is the usual case. + let left = anchorX + ANCHOR_GAP; + if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) { + left = anchorX - POPUP_WIDTH - ANCHOR_GAP; + } + left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN)); + + // Align the top with the click, then lift the card just enough to fit. let top = anchorY; - - // Flip horizontally if too close to right edge - if (vw - anchorX < EDGE_THRESHOLD) { - left = anchorX - POPUP_WIDTH; + if (top + height > vh - VIEWPORT_MARGIN) { + top = vh - height - VIEWPORT_MARGIN; } + top = Math.max(VIEWPORT_MARGIN, top); - // Flip vertically if too close to bottom edge - if (vh - anchorY < EDGE_THRESHOLD) { - top = anchorY - 300; // approximate popup height - } - - // Clamp to viewport - left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8)); - top = Math.max(8, top); - - return { left, top }; + el.style.left = `${left}px`; + el.style.top = `${top}px`; } function buildAvatar(user: UserProfileData): HTMLDivElement { @@ -182,17 +194,8 @@ export function createUserProfilePopup( "data-testid": "user-profile-popup", }); - // Position the popup - const pos = computePosition(options.anchorX, options.anchorY); - popup.style.left = `${pos.left}px`; - popup.style.top = `${pos.top}px`; popup.style.width = `${POPUP_WIDTH}px`; - // Animation: fade + scale - popup.style.opacity = "0"; - popup.style.transform = "scale(0.95)"; - popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`; - // --- Content --- // Avatar @@ -298,10 +301,12 @@ export function createUserProfilePopup( actions.appendChild(callBtn); } - // Assemble popup + // Assemble the card: a banner strip and a body, with the avatar straddling + // the seam between them the way Discord's popout does. + const banner = createElement("div", { class: "upp-banner" }); + const body = createElement("div", { class: "upp-body" }); appendChildren( - popup, - avatar, + body, nameEl, handleEl, customStatusEl, @@ -311,17 +316,24 @@ export function createUserProfilePopup( joinSection, ); if (actions.childElementCount > 0) { - appendChildren(popup, divider, actions); + appendChildren(body, divider, actions); } + // The avatar hangs off the body's top edge, so it is a child of the card + // rather than the body — the body scrolls, and a scroll container clips. + // Appending it last puts it over the banner without needing a z-index. + appendChildren(popup, banner, body, avatar); overlay.appendChild(popup); container.appendChild(overlay); - // Trigger animation + // Measure, then place: the card has to be in the document before it has a + // height to clamp against. + position(popup, options.anchorX, options.anchorY); + + // The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it. requestAnimationFrame(() => { if (popup !== null) { - popup.style.opacity = "1"; - popup.style.transform = "scale(1)"; + popup.classList.add("open"); } }); diff --git a/Client/tauri-client/src/components/channel-sidebar/context-menu.ts b/Client/tauri-client/src/components/channel-sidebar/context-menu.ts index 1a94cd3e..f176900d 100644 --- a/Client/tauri-client/src/components/channel-sidebar/context-menu.ts +++ b/Client/tauri-client/src/components/channel-sidebar/context-menu.ts @@ -182,7 +182,10 @@ export function attachChannelContextMenu( menu.remove(); menuAc.abort(); }; - signal.addEventListener("abort", () => menuAc.abort()); + // Tie this bridge listener's own lifetime to menuAc so it does not + // outlive the menu it belongs to — closeMenu (which aborts menuAc) + // already fires far more often than the sidebar's own teardown. + signal.addEventListener("abort", () => menuAc.abort(), { signal: menuAc.signal }); // Defer so this click event doesn't immediately close it setTimeout(() => { if (menuAc.signal.aborted) return; diff --git a/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts b/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts index 1485a8cf..32425719 100644 --- a/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts +++ b/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts @@ -1,13 +1,13 @@ /** * Channel drag-reorder — mouse-based drag-and-drop for channel reordering. * Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues). - * Admin/owner only. + * Gated on MANAGE_CHANNELS, like every other channel-management affordance. */ -import { getCurrentUser } from "@stores/auth.store"; import { updateChannelPosition } from "@stores/channels.store"; import type { Channel } from "@stores/channels.store"; import type { ChannelReorderData } from "../ChannelSidebar"; +import { canManageChannels } from "@lib/permissions"; // ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ── interface DragState { @@ -16,17 +16,49 @@ interface DragState { containerEl: HTMLElement; channels: readonly Channel[]; onReorder: (reorders: readonly ChannelReorderData[]) => void; + /** The signal of the sidebar that started this drag, so its teardown can + * clear the in-flight visual state without touching another sidebar's. */ + owner: AbortSignal; } let activeDrag: DragState | null = null; -/** Global mousemove/mouseup handlers for drag reordering. Registered once. - * Reference-counted so multiple sidebar instances share the same listeners - * and only the last destroy tears them down. */ +/** Global mousemove/mouseup handlers for drag reordering, shared by every + * sidebar instance. Ownership is tracked per AbortSignal — the sidebar's + * lifetime controller — not per attached channel row: attachDragHandlers runs + * once per row per render, and the per-row ref-count this replaced meant a + * sidebar took N references its single destroy could never return, so the two + * document listeners lived for the rest of the process (the KNOWN BUG + * drag-reorder.test.ts pinned until this fix). An owner's release is its + * signal's abort — the same AbortController teardown idiom as + * {@link ../../lib/disposable} — so there is no separate release call to + * forget or miscount. */ +const listenerOwners = new Set<AbortSignal>(); let globalDragAc: AbortController | null = null; -let globalDragRefCount = 0; -export function ensureGlobalDragListeners(): void { - globalDragRefCount++; +function releaseOwner(owner: AbortSignal): void { + listenerOwners.delete(owner); + // A sidebar destroyed mid-drag must not leave the row stuck in the dragging + // state or the body stuck in reorder mode. + if (activeDrag !== null && activeDrag.owner === owner) { + activeDrag.sourceEl.classList.remove("dragging"); + document.body.classList.remove("channel-reordering"); + activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => { + x.classList.remove("channel-drop-indicator"); + }); + activeDrag = null; + } + if (listenerOwners.size === 0 && globalDragAc !== null) { + globalDragAc.abort(); + globalDragAc = null; + } +} + +export function ensureGlobalDragListeners(owner: AbortSignal): void { + if (owner.aborted || listenerOwners.has(owner)) { + return; + } + listenerOwners.add(owner); + owner.addEventListener("abort", () => releaseOwner(owner), { once: true }); if (globalDragAc !== null) { return; } @@ -133,7 +165,7 @@ export function ensureGlobalDragListeners(): void { ); } -/** Make a channel element draggable via mousedown (admin/owner only). */ +/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */ export function attachDragHandlers( el: HTMLElement, channel: Channel, @@ -145,13 +177,15 @@ export function attachDragHandlers( if (onReorderChannel === undefined) { return; } - const user = getCurrentUser(); - const role = user?.role?.toLowerCase() ?? ""; - if (role !== "owner" && role !== "admin") { + // The one derivation for every channel-management affordance (create, edit, + // delete, reorder) — a custom role holding the bit gets the same rows the + // Edit/Delete menu already offers it, and a role merely *named* "admin" + // without the bit does not get a drag the server will 403. + if (!canManageChannels()) { return; } - ensureGlobalDragListeners(); + ensureGlobalDragListeners(signal); el.classList.add("channel-draggable"); el.dataset.dragChannelId = String(channel.id); @@ -173,6 +207,15 @@ export function attachDragHandlers( el.addEventListener( "mousemove", (e) => { + // Defuse a stale latch: pendingDrag is cleared only by a mouseup on + // this same row (see the listener below), so releasing the button + // anywhere else — off this row entirely, or via a fast flick — leaves + // it armed. A later button-free hover would otherwise promote it into + // a real drag on the next `if` below. + if (e.buttons === 0) { + pendingDrag = null; + return; + } if (pendingDrag === null || activeDrag !== null) { return; } @@ -189,6 +232,7 @@ export function attachDragHandlers( containerEl, channels, onReorder: onReorderChannel, + owner: signal, }; el.classList.add("dragging"); document.body.classList.add("channel-reordering"); @@ -204,18 +248,3 @@ export function attachDragHandlers( { signal }, ); } - -/** Decrement global drag listener ref-count; tear down when no more sidebars. */ -export function releaseGlobalDragListeners(containerEl?: HTMLElement): void { - // Clear stale drag state if the destroyed sidebar owns the active drag - if (containerEl !== undefined && activeDrag?.containerEl === containerEl) { - activeDrag.sourceEl.classList.remove("dragging"); - document.body.classList.remove("channel-reordering"); - activeDrag = null; - } - globalDragRefCount = Math.max(0, globalDragRefCount - 1); - if (globalDragRefCount === 0 && globalDragAc !== null) { - globalDragAc.abort(); - globalDragAc = null; - } -} diff --git a/Client/tauri-client/src/components/inline-autocomplete.ts b/Client/tauri-client/src/components/inline-autocomplete.ts index f76748f5..60df6c9d 100644 --- a/Client/tauri-client/src/components/inline-autocomplete.ts +++ b/Client/tauri-client/src/components/inline-autocomplete.ts @@ -7,6 +7,13 @@ * navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here * once instead of being duplicated in each popup. * + * Accessibility-wise this is a WAI-ARIA combobox, not a menu: DOM focus stays + * in the composer textarea the whole time (moving it into the list would stop + * keystrokes from reaching the textarea, so the rows deliberately get no + * roving tabindex) and the "focused" row is conveyed purely through + * aria-activedescendant on the textarea, pointing at per-row ids stamped on + * every render. + * * Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. */ @@ -40,6 +47,15 @@ export interface InlineAutocompleteConfig<T> { readonly onSelect: (value: string) => void; /** Called when the user dismisses the popup (Escape). */ readonly onClose: () => void; + /** + * The composer control this popup completes for (the textarea). While the + * popup exists it carries combobox semantics — role="combobox", + * aria-autocomplete="list", aria-expanded="true", aria-controls={list id} — + * plus aria-activedescendant tracking the active row; destroy() removes + * them all again. DOM focus never moves here: it must stay in the textarea + * so typing keeps working, which is why the rows have no tabindex. + */ + readonly comboboxInput?: HTMLElement; } export interface InlineAutocompleteComponent { @@ -54,6 +70,15 @@ export interface InlineAutocompleteComponent { destroy(): void; } +/** The combobox state a popup stamps on its input, removed again on destroy. */ +const COMBOBOX_ATTRS = [ + "role", + "aria-autocomplete", + "aria-expanded", + "aria-controls", + "aria-activedescendant", +] as const; + export function createInlineAutocomplete<T>( cfg: InlineAutocompleteConfig<T>, ): InlineAutocompleteComponent { @@ -63,14 +88,29 @@ export function createInlineAutocomplete<T>( let suggestions: T[] = []; let activeIndex = 0; + // The testid is already unique per widget, so it doubles as a stable DOM id + // for aria-controls / aria-activedescendant to point at. + const rootId = cfg.rootTestId; + const root = createElement("div", { class: cfg.rootClass, + id: rootId, role: "listbox", "data-testid": cfg.rootTestId, }); const list = createElement("div", { class: "ma-list" }); root.appendChild(list); + const input = cfg.comboboxInput ?? null; + if (input !== null) { + input.setAttribute("role", "combobox"); + input.setAttribute("aria-autocomplete", "list"); + // The popup only exists while it is open (the composer destroys it to + // close), so "expanded" holds for this component's whole lifetime. + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-controls", rootId); + } + function choose(index: number): void { const picked = suggestions[index]; if (picked === undefined) return; @@ -83,6 +123,7 @@ export function createInlineAutocomplete<T>( const s = suggestions[i]!; const row = createElement("div", { class: i === activeIndex ? "ma-item ma-item--active" : "ma-item", + id: `${rootId}-option-${i}`, role: "option", "aria-selected": i === activeIndex ? "true" : "false", "data-testid": cfg.rowTestId(s), @@ -100,6 +141,15 @@ export function createInlineAutocomplete<T>( ); list.appendChild(row); } + // Rows are rebuilt with index-based ids, so the pointer must be re-aimed + // on every render, not just when activeIndex moves. + if (input !== null) { + if (suggestions.length > 0) { + input.setAttribute("aria-activedescendant", `${rootId}-option-${activeIndex}`); + } else { + input.removeAttribute("aria-activedescendant"); + } + } } function setQuery(query: string): boolean { @@ -138,6 +188,12 @@ export function createInlineAutocomplete<T>( function destroy(): void { ac.abort(); + // Another popup may have claimed the input between this one's open and + // close (the composer opens the mention popup before closing the emoji + // one), so only strip the combobox state while it still points here. + if (input !== null && input.getAttribute("aria-controls") === rootId) { + for (const attr of COMBOBOX_ATTRS) input.removeAttribute(attr); + } root.remove(); } diff --git a/Client/tauri-client/src/components/message-list/reaction-tooltip.ts b/Client/tauri-client/src/components/message-list/reaction-tooltip.ts index bee2f1bc..4f2a496f 100644 --- a/Client/tauri-client/src/components/message-list/reaction-tooltip.ts +++ b/Client/tauri-client/src/components/message-list/reaction-tooltip.ts @@ -221,6 +221,33 @@ interface HoverState { const hoverStates = new WeakMap<HTMLElement, HoverState>(); +/** + * Chips currently mid-hover (debounce timer running or tooltip showing), + * keyed by the message list's AbortSignal. A single abort listener per signal + * hides whatever is in the set instead of registering a bare, never-removed + * `abort` listener per chip on every render — the latter permanently pinned + * every past chip (and, via parentNode, its whole detached row) in memory for + * the rest of the channel visit. start()/stop() add/remove the chip, so the + * set only ever holds the handful of chips actually being hovered. + */ +const hoveringChips = new WeakMap<AbortSignal, Set<HTMLElement>>(); + +function chipSetFor(signal: AbortSignal): Set<HTMLElement> { + const existing = hoveringChips.get(signal); + if (existing !== undefined) return existing; + const set = new Set<HTMLElement>(); + hoveringChips.set(signal, set); + signal.addEventListener( + "abort", + () => { + for (const chip of set) hide(chip); + set.clear(); + }, + { once: true }, + ); + return set; +} + function removeTooltip(chip: HTMLElement): void { chip.querySelector(".reaction-tooltip")?.remove(); } @@ -261,21 +288,25 @@ export function attachReactionTooltip( }); }; + const chips = chipSetFor(signal); + const start = (): void => { hide(chip); const existing = hoverStates.get(chip); const generation = existing === undefined ? 0 : existing.generation; const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS); hoverStates.set(chip, { timer, generation }); + chips.add(chip); }; - const stop = (): void => hide(chip); + const stop = (): void => { + chips.delete(chip); + hide(chip); + }; chip.addEventListener("mouseenter", start, { signal }); chip.addEventListener("mouseleave", stop, { signal }); // Keyboard accessibility: focus mirrors hover. chip.addEventListener("focusin", start, { signal }); chip.addEventListener("focusout", stop, { signal }); - - signal.addEventListener("abort", () => hide(chip)); } diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index 558387bd..735d894d 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -179,9 +179,16 @@ function buildVoiceAudioTabInner( const onUp = (): void => { meterThreshold.removeEventListener("pointermove", onMove); meterThreshold.removeEventListener("pointerup", onUp); + meterThreshold.removeEventListener("pointercancel", onUp); }; meterThreshold.addEventListener("pointermove", onMove, { signal }); meterThreshold.addEventListener("pointerup", onUp, { signal }); + // A touch/pen drag that the OS claims as a pan (or any other + // mid-drag pointer loss) fires pointercancel instead of pointerup. + // Without this, onMove stays attached for the tab's lifetime and + // every later hover over the handle silently rewrites and persists + // voiceSensitivity with no button held (v097). + meterThreshold.addEventListener("pointercancel", onUp, { signal }); }, { signal }, ); @@ -411,10 +418,14 @@ function buildVoiceAudioTabInner( { signal }, ); - // Race guard: prevent stale getUserMedia results from overwriting a newer request + // Race guard: prevent stale getUserMedia results from overwriting a newer + // request. cleanupMic() invalidates both counters, so a stream resolving + // after teardown is stopped instead of re-arming state nobody cleans up. let cameraRequestId = 0; + let micRequestId = 0; registerCameraInvalidation(() => { cameraRequestId += 1; + micRequestId += 1; }); function stopCameraPreview(): void { @@ -482,6 +493,7 @@ function buildVoiceAudioTabInner( // Start mic level monitoring for visual feedback void (async () => { + const thisRequest = ++micRequestId; try { const savedDevice = loadPref<string>("audioInputDevice", ""); const constraints: MediaStreamConstraints = { @@ -489,6 +501,13 @@ function buildVoiceAudioTabInner( video: false, }; const stream = await navigator.mediaDevices.getUserMedia(constraints); + // Race guard: teardown (cleanup or abort) may have run while we awaited + // — opening the mic now would leave it hot with nobody left to stop it, + // and registerMic would re-arm state cleanupMic() already cleared. + if (signal.aborted || thisRequest !== micRequestId) { + for (const track of stream.getTracks()) track.stop(); + return; + } const audioCtx = new AudioContext(); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; diff --git a/Client/tauri-client/src/generated/.typecache b/Client/tauri-client/src/generated/.typecache deleted file mode 100644 index eaf1fd9e..00000000 --- a/Client/tauri-client/src/generated/.typecache +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 1, - "commands_hash": "ca3b770e3d69abf7", - "structs_hash": "2c0574a96a92e42f", - "config_hash": "c72a07caa5bc6ed4", - "combined_hash": "6a107ade235e2401" -} \ No newline at end of file diff --git a/Client/tauri-client/src/generated/commands.ts b/Client/tauri-client/src/generated/commands.ts deleted file mode 100644 index d5b9393f..00000000 --- a/Client/tauri-client/src/generated/commands.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Auto-generated TypeScript bindings for Tauri commands - * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-04-03T09:09:31.628896400+00:00 - * Generator: none - * - * Do not edit manually - regenerate using: cargo tauri-typegen generate - */ - -import { invoke } from "@tauri-apps/api/core"; -import * as types from "./types"; - -export async function startLivekitProxy(params: types.StartLivekitProxyParams): Promise<number> { - return invoke("start_livekit_proxy", params); -} - -export async function stopLivekitProxy(): Promise<void> { - return invoke("stop_livekit_proxy"); -} - -export async function checkClientUpdate( - params: types.CheckClientUpdateParams, -): Promise<types.UpdateCheckResult> { - return invoke("check_client_update", params); -} - -export async function downloadAndInstallUpdate( - params: types.DownloadAndInstallUpdateParams, -): Promise<void> { - return invoke("download_and_install_update", params); -} - -export async function pttStart(): Promise<void> { - return invoke("ptt_start"); -} - -export async function pttStop(): Promise<void> { - return invoke("ptt_stop"); -} - -export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> { - return invoke("ptt_set_key", params); -} - -export async function pttGetKey(): Promise<number> { - return invoke("ptt_get_key"); -} - -export async function pttListenForKey(): Promise<number> { - return invoke("ptt_listen_for_key"); -} - -export async function saveCredential(params: types.SaveCredentialParams): Promise<void> { - return invoke("save_credential", params); -} - -export async function loadCredential( - params: types.LoadCredentialParams, -): Promise<types.CredentialData | null> { - return invoke("load_credential", params); -} - -export async function deleteCredential(params: types.DeleteCredentialParams): Promise<void> { - return invoke("delete_credential", params); -} - -export async function wsConnect(params: types.WsConnectParams): Promise<void> { - return invoke("ws_connect", params); -} - -export async function wsSend(params: types.WsSendParams): Promise<void> { - return invoke("ws_send", params); -} - -export async function wsDisconnect(): Promise<void> { - return invoke("ws_disconnect"); -} - -export async function acceptCertFingerprint( - params: types.AcceptCertFingerprintParams, -): Promise<void> { - return invoke("accept_cert_fingerprint", params); -} - -export async function getSettings(): Promise<types.Value> { - return invoke("get_settings"); -} - -export async function saveSettings(params: types.SaveSettingsParams): Promise<void> { - return invoke("save_settings", params); -} - -export async function storeCertFingerprint( - params: types.StoreCertFingerprintParams, -): Promise<void> { - return invoke("store_cert_fingerprint", params); -} - -export async function getCertFingerprint( - params: types.GetCertFingerprintParams, -): Promise<string | null> { - return invoke("get_cert_fingerprint", params); -} - -export async function openDevtools(): Promise<void> { - return invoke("open_devtools"); -} diff --git a/Client/tauri-client/src/generated/events.ts b/Client/tauri-client/src/generated/events.ts deleted file mode 100644 index a97f7d62..00000000 --- a/Client/tauri-client/src/generated/events.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Auto-generated TypeScript bindings for Tauri commands - * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-04-03T09:09:31.629251800+00:00 - * Generator: none - * - * Do not edit manually - regenerate using: cargo tauri-typegen generate - */ - -/** - * Event Listeners - * Type-safe event listener helpers for Tauri events - */ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import * as types from "./types"; - -/** - * Listen for 'status-change' events - * @param handler - Callback function to handle the event - * @returns Promise that resolves to an unlisten function - */ -export async function onStatusChange(handler: (payload: string) => void): Promise<UnlistenFn> { - return listen<string>("status-change", (event) => { - handler(event.payload); - }); -} - -/** - * Listen for 'ws-state' events - * @param handler - Callback function to handle the event - * @returns Promise that resolves to an unlisten function - */ -export async function onWsState(handler: (payload: string) => void): Promise<UnlistenFn> { - return listen<string>("ws-state", (event) => { - handler(event.payload); - }); -} - -/** - * Listen for 'cert-tofu' events - * @param handler - Callback function to handle the event - * @returns Promise that resolves to an unlisten function - */ -export async function onCertTofu(handler: (payload: types.Value) => void): Promise<UnlistenFn> { - return listen<types.Value>("cert-tofu", (event) => { - handler(event.payload); - }); -} diff --git a/Client/tauri-client/src/generated/index.ts b/Client/tauri-client/src/generated/index.ts deleted file mode 100644 index 01678db3..00000000 --- a/Client/tauri-client/src/generated/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Auto-generated TypeScript bindings for Tauri commands - * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-04-03T09:09:31.629428700+00:00 - * Generator: none - * - * Do not edit manually - regenerate using: cargo tauri-typegen generate - */ - -export * from "./types"; -export * from "./commands"; -export * from "./events"; diff --git a/Client/tauri-client/src/generated/types.ts b/Client/tauri-client/src/generated/types.ts deleted file mode 100644 index ffa7df95..00000000 --- a/Client/tauri-client/src/generated/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Auto-generated TypeScript bindings for Tauri commands - * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-04-03T09:09:31.628377200+00:00 - * Generator: none - * - * Do not edit manually - regenerate using: cargo tauri-typegen generate - */ - -export interface UpdateCheckResult { - available: boolean; - version?: string | null; - body?: string | null; -} - -export type Value = unknown; - -export interface CredentialData { - username: string; - token: string; -} - -export interface StartLivekitProxyParams { - remoteHost: string; - [key: string]: unknown; -} - -export interface CheckClientUpdateParams { - serverUrl: string; - [key: string]: unknown; -} - -export interface DownloadAndInstallUpdateParams { - serverUrl: string; - [key: string]: unknown; -} - -export interface PttSetKeyParams { - vkCode: number; - [key: string]: unknown; -} - -export interface SaveCredentialParams { - host: string; - username: string; - token: string; - password?: string | null; - [key: string]: unknown; -} - -export interface LoadCredentialParams { - host: string; - [key: string]: unknown; -} - -export interface DeleteCredentialParams { - host: string; - [key: string]: unknown; -} - -export interface WsConnectParams { - url: string; - [key: string]: unknown; -} - -export interface WsSendParams { - message: string; - [key: string]: unknown; -} - -export interface AcceptCertFingerprintParams { - host: string; - fingerprint: string; - [key: string]: unknown; -} - -export interface SaveSettingsParams { - key: string; - value: Value; - [key: string]: unknown; -} - -export interface StoreCertFingerprintParams { - host: string; - fingerprint: string; - [key: string]: unknown; -} - -export interface GetCertFingerprintParams { - host: string; - [key: string]: unknown; -} diff --git a/Client/tauri-client/src/lib/a11y.ts b/Client/tauri-client/src/lib/a11y.ts new file mode 100644 index 00000000..cd27f3e8 --- /dev/null +++ b/Client/tauri-client/src/lib/a11y.ts @@ -0,0 +1,153 @@ +/** + * Shared dialog accessibility helpers (DC-13). + * + * Generalizes the pattern UserProfilePopup pioneered — dialog semantics, a + * Tab-cycling focus trap, and focus save/restore — so every modal applies the + * same behavior instead of re-implementing (or forgetting) it. All listeners + * register against the caller's AbortSignal, matching the component teardown + * idiom used across the codebase. + */ + +/** The elements a dialog's Tab cycle visits. */ +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export interface DialogSemanticsOptions { + /** Accessible name for the dialog (aria-label). */ + readonly label?: string; + /** Id of the element naming the dialog (aria-labelledby); wins over label. */ + readonly labelledBy?: string; +} + +/** + * Stamp WAI-ARIA dialog semantics on a modal container: role="dialog", + * aria-modal="true", and tabindex="-1" so the container itself can take + * initial focus when it holds no focusable control. + */ +export function applyDialogSemantics(el: HTMLElement, opts: DialogSemanticsOptions = {}): void { + el.setAttribute("role", "dialog"); + el.setAttribute("aria-modal", "true"); + el.setAttribute("tabindex", "-1"); + if (opts.labelledBy !== undefined) { + el.setAttribute("aria-labelledby", opts.labelledBy); + } else if (opts.label !== undefined) { + el.setAttribute("aria-label", opts.label); + } +} + +/** + * Trap Tab/Shift+Tab inside `container` for as long as `signal` lives: + * tabbing past the last focusable wraps to the first and vice versa. The + * focusable set is queried per keystroke, so contents may change freely. + */ +export function trapFocus(container: HTMLElement, signal: AbortSignal): void { + container.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR); + if (focusable.length === 0) { + // Nothing tabbable inside — keep focus on the container itself. + e.preventDefault(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + // Focus outside the set (e.g. on the container) also wraps to an edge. + const active = document.activeElement; + if (e.shiftKey && (active === first || active === container)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (active === last || active === container)) { + e.preventDefault(); + first.focus(); + } + }, + { signal }, + ); +} + +/** + * Make exactly one cell in `container` tabbable (the first) and the rest + * focusable only programmatically. Call after every render that replaces the + * cell set — search results swap the cells out from under the tabindex, and a + * grid with zero (or many) Tab stops breaks the "Tab enters the grid once" + * contract. + */ +export function setRovingTabindex(container: HTMLElement, cellSelector: string): void { + const cells = container.querySelectorAll<HTMLElement>(cellSelector); + cells.forEach((cell, i) => { + cell.setAttribute("tabindex", i === 0 ? "0" : "-1"); + }); +} + +/** + * Roving-tabindex keyboard support for a flat list of option cells: + * ArrowLeft/ArrowRight step, Home/End jump to the edges, and Enter/Space + * activate the focused cell through its own click handler so keyboard and + * mouse take the identical code path. The grid is deliberately treated as a + * flat list — row-aware Up/Down would need layout knowledge the DOM doesn't + * expose reliably. + * + * The listener lives on the container (which survives re-renders) and the + * cell set is queried per keystroke, so callers may rebuild cells freely as + * long as they re-run setRovingTabindex afterwards. + */ +export function enableRovingNavigation( + container: HTMLElement, + cellSelector: string, + signal: AbortSignal, +): void { + container.addEventListener( + "keydown", + (e: KeyboardEvent) => { + // Only keystrokes originating on a cell rove; the search input above + // the grid keeps its native caret behavior for arrows and Home/End. + const origin = + e.target instanceof HTMLElement ? e.target.closest<HTMLElement>(cellSelector) : null; + if (origin === null) return; + const cells = Array.from(container.querySelectorAll<HTMLElement>(cellSelector)); + const from = cells.indexOf(origin); + if (from === -1) return; + + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + origin.click(); + return; + } + + let to: number; + if (e.key === "ArrowRight") to = Math.min(from + 1, cells.length - 1); + else if (e.key === "ArrowLeft") to = Math.max(from - 1, 0); + else if (e.key === "Home") to = 0; + else if (e.key === "End") to = cells.length - 1; + else return; + + e.preventDefault(); + // Move the single Tab stop along with focus so tabbing away and back + // returns to the last visited cell, not the first. + origin.setAttribute("tabindex", "-1"); + const target = cells[to]!; + target.setAttribute("tabindex", "0"); + target.focus(); + }, + { signal }, + ); +} + +/** + * Move initial focus into a just-opened dialog (its first focusable control, + * else the container itself) and return a restorer that puts focus back on + * whatever held it before — call the restorer on close. Capturing happens NOW, + * so call this before anything inside the dialog grabs focus. + */ +export function focusDialog(container: HTMLElement): () => void { + const previous = document.activeElement; + const firstFocusable = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR); + (firstFocusable ?? container).focus(); + return () => { + if (previous instanceof HTMLElement && previous.isConnected) { + previous.focus(); + } + }; +} diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 73056b70..230452b6 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -17,9 +17,7 @@ import type { ChannelType, ChannelResponse, EmojiResponse, - SoundResponse, InviteResponse, - SessionResponse, UploadResponse, VoiceCredentialsResponse, MemberResponse, @@ -51,6 +49,30 @@ export class ApiClientError extends Error { export type OnUnauthorized = () => void; +/** + * Single session object from GET /users/me/sessions, matching the server's + * wire shape (Server/api/profile_handler.go's sessionResponse, wrapped in a + * `{sessions: [...]}` envelope — docs/api.md). Defined here, next to its only + * consumer, rather than in `./types`: the declaration that used to live there + * had drifted from the actual contract (it declared `ip_address`/`expires_at`, + * which the server never sends, and omitted `ip`/`is_current`, which it always + * does), and nothing else needs this shape. + */ +export interface SessionInfo { + readonly id: number; + /** Never null: the server's fields are plain Go strings, so an unknown + * device or address arrives as "" rather than being omitted. */ + readonly device: string; + readonly ip: string; + readonly created_at: string; + readonly last_used: string; + readonly is_current: boolean; +} + +interface SessionsListResponse { + readonly sessions: SessionInfo[]; +} + const log = createLogger("api"); /** Create the REST API client. */ @@ -334,7 +356,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: return request<void>( "PUT", "/users/me/password", - { current_password: currentPassword, new_password: newPassword }, + { old_password: currentPassword, new_password: newPassword }, signal, ); }, @@ -354,8 +376,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: return request<void>("DELETE", "/users/me/totp", { password }, signal); }, - getSessions(signal?: AbortSignal): Promise<SessionResponse[]> { - return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal); + getSessions(signal?: AbortSignal): Promise<SessionInfo[]> { + return request<SessionsListResponse>("GET", "/users/me/sessions", undefined, signal).then( + (r) => r.sessions, + ); }, revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> { @@ -589,16 +613,6 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal); }, - // ── Sounds ──────────────────────────────────────────── - - getSounds(signal?: AbortSignal): Promise<SoundResponse[]> { - return request<SoundResponse[]>("GET", "/sounds", undefined, signal); - }, - - deleteSound(soundId: number, signal?: AbortSignal): Promise<void> { - return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal); - }, - // ── Direct Messages ───────────────────────────────────── /** List user's open DM channels. */ diff --git a/Client/tauri-client/src/lib/audioPipeline.ts b/Client/tauri-client/src/lib/audioPipeline.ts index 69a248dc..272abb23 100644 --- a/Client/tauri-client/src/lib/audioPipeline.ts +++ b/Client/tauri-client/src/lib/audioPipeline.ts @@ -21,6 +21,11 @@ export class AudioPipeline { /** Monotonic counter incremented on teardown — used to discard stale async results. */ private _pipelineGeneration = 0; + /** Monotonic counter incremented on stopVadPolling — narrower than + * _pipelineGeneration (which only bumps on a full pipeline teardown), so it + * also invalidates an in-flight startVadPolling()'s addModule when VAD is + * stopped without tearing down the pipeline (e.g. setVoiceSensitivity(100)). */ + private _vadGeneration = 0; // Pipeline nodes private audioPipelineCtx: AudioContext | null = null; @@ -270,15 +275,18 @@ export class AudioPipeline { // Try AudioWorklet first const gen = this._pipelineGeneration; + const vadGen = this._vadGeneration; this.audioPipelineCtx.audioWorklet .addModule("/vad-worklet.js") .then(() => { if (gen !== this._pipelineGeneration) return; // Torn down while loading + if (vadGen !== this._vadGeneration) return; // stopVadPolling() while loading if (this.audioPipelineCtx === null) return; this.startVadWorklet(threshold); }) .catch((err) => { if (gen !== this._pipelineGeneration) return; + if (vadGen !== this._vadGeneration) return; log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err); this.startVadFallback(threshold); }); @@ -389,6 +397,7 @@ export class AudioPipeline { /** Stop VAD (both worklet and fallback). Pipeline stays intact. */ stopVadPolling(): void { + this._vadGeneration++; // Stop setTimeout fallback if (this.vadTimer !== null) { clearTimeout(this.vadTimer); diff --git a/Client/tauri-client/src/lib/autoIdle.ts b/Client/tauri-client/src/lib/autoIdle.ts index fc5170d0..98b36f3d 100644 --- a/Client/tauri-client/src/lib/autoIdle.ts +++ b/Client/tauri-client/src/lib/autoIdle.ts @@ -86,8 +86,11 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController { let destroyed = false; /** True while the timer is the reason the status is idle. Kept in memory so * the hot path (one mousemove per pixel) is a boolean check rather than a - * preference read. */ - let idleByTimer = false; + * preference read. Seeded from the persisted status/origin so a session + * that starts already auto-idle (app restart, MainPage remount) can still + * be un-idled by activity — otherwise the latch starts false and apply(false) + * is unreachable until the user manually reselects a status. */ + let idleByTimer = loadUserStatus() === "idle" && loadUserStatusOrigin() === "auto"; function apply(idle: boolean): void { const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle); diff --git a/Client/tauri-client/src/lib/cert-reconnect.ts b/Client/tauri-client/src/lib/cert-reconnect.ts new file mode 100644 index 00000000..a444d3a3 --- /dev/null +++ b/Client/tauri-client/src/lib/cert-reconnect.ts @@ -0,0 +1,47 @@ +/** + * cert-reconnect — resume a WS connection after the user accepts a rotated + * TLS certificate fingerprint (TOFU mismatch flow). + * + * Extracted out of main.ts, which has no unit-test seam of its own (it wires + * the DOM, router and stores together at startup and is exercised at the + * e2e level — see vitest.config.ts's coverage excludes) so this one piece of + * retry logic can be tested directly. + */ + +export interface CertReconnectWs { + connect(cfg: { readonly host: string; readonly token: string }): void; + onStateChange(listener: (state: string) => void): () => void; +} + +export interface CertReconnectRouter { + getCurrentPage(): string; + navigate(page: string): void; +} + +/** + * Reconnect after the user accepts a rotated certificate fingerprint. + * + * wirePostAuth's own onStateChange handler unsubscribes itself the moment it + * sees "disconnected" (so a later transition can't fire it a second time) — + * and the mismatch that triggered this retry is exactly the "disconnected" + * transition that did so. A bare `ws.connect()` here would therefore + * reconnect into a page with nothing left listening to leave the connect + * screen. Re-register a one-shot navigator first, unless the app already + * reached "main" (mismatch arrived after login, socket already live there). + */ +export function reconnectAfterCertAccept( + ws: CertReconnectWs, + router: CertReconnectRouter, + host: string, + token: string, +): void { + if (router.getCurrentPage() !== "main") { + const unsub = ws.onStateChange((state) => { + if (state === "connected") { + unsub(); + router.navigate("main"); + } + }); + } + ws.connect({ host, token }); +} diff --git a/Client/tauri-client/src/lib/channel-mutes.ts b/Client/tauri-client/src/lib/channel-mutes.ts index 78a8cb19..0358f382 100644 --- a/Client/tauri-client/src/lib/channel-mutes.ts +++ b/Client/tauri-client/src/lib/channel-mutes.ts @@ -23,6 +23,21 @@ import { loadPref, savePref } from "./preferences"; /** localStorage key (under the shared settings prefix). */ const MUTED_KEY = "mutedChannels"; +/** + * Server host the mutes below belong to. The app is multi-server (saved + * profiles keyed by host, all sharing one Tauri webview origin and therefore + * one localStorage), and channel ids are per-server SQLite autoincrement + * integers — without a host component in the key, muting channel 7 on one + * server silently mutes channel 7 on every other server too. `null` (the + * startup default, before any host is known) falls back to the original + * unscoped key so a pre-scoping install's mutes are not orphaned. + */ +let currentHost: string | null = null; + +function mutedKey(): string { + return currentHost === null ? MUTED_KEY : `${MUTED_KEY}:${currentHost}`; +} + /** * Cached parse of the stored list. Notification gating runs on every incoming * message, and a JSON.parse per message for a list that changes on a menu @@ -32,9 +47,22 @@ const MUTED_KEY = "mutedChannels"; */ let cache: ReadonlySet<number> | null = null; +/** + * Point mute reads/writes at a specific server's key and drop the cache so + * the next read re-parses under the new key instead of returning the + * previous server's set. Call on connect and on server switch — mirroring + * how `read-state.ts`'s `setMarkReadSender` and `ui.store.ts`'s + * `loadCollapsedCategories` are wired from MainPage per-connection. + */ +export function setChannelMutesHost(host: string | null): void { + if (host === currentHost) return; + currentHost = host; + invalidateMuteCache(); +} + function readMuted(): ReadonlySet<number> { if (cache !== null) return cache; - const raw = loadPref<unknown[]>(MUTED_KEY, []); + const raw = loadPref<unknown[]>(mutedKey(), []); const ids = new Set<number>(); if (Array.isArray(raw)) { for (const v of raw) { @@ -49,7 +77,7 @@ function readMuted(): ReadonlySet<number> { function writeMuted(ids: ReadonlySet<number>): void { cache = ids; - savePref(MUTED_KEY, [...ids]); + savePref(mutedKey(), [...ids]); } /** Drop the cached parse. Exported for tests and for logout. */ @@ -60,7 +88,7 @@ export function invalidateMuteCache(): void { if (typeof window !== "undefined") { window.addEventListener("owncord:pref-change", (e) => { const detail = (e as CustomEvent<{ key?: string }>).detail; - if (detail?.key === MUTED_KEY) invalidateMuteCache(); + if (detail?.key === mutedKey()) invalidateMuteCache(); }); // Cross-tab: the native storage event fires only in the *other* tab. window.addEventListener("storage", () => invalidateMuteCache()); diff --git a/Client/tauri-client/src/lib/channel-navigation.ts b/Client/tauri-client/src/lib/channel-navigation.ts index 795a3782..529d43ee 100644 --- a/Client/tauri-client/src/lib/channel-navigation.ts +++ b/Client/tauri-client/src/lib/channel-navigation.ts @@ -5,6 +5,7 @@ */ import { setActiveChannel, clearUnread, channelsStore } from "@stores/channels.store"; +import { clearDmUnread } from "@stores/dm.store"; /** * Activate `channelId`, clearing its unread and mention badges. @@ -17,6 +18,13 @@ export function navigateToChannel(channelId: number): void { if (!channelsStore.getState().channels.has(channelId)) return; setActiveChannel(channelId); clearUnread(channelId); + // findChannelById does not filter out DM mirrors, so a jump (permalink, + // search, pinned, reply) can land on a `type: "dm"` channel. Its unread + // badge lives in dmStore, not channelsStore — clearUnread alone leaves the + // DM sidebar row lit while the user is reading it. No-op for a non-DM id + // (dmStore has no matching channel), mirroring markChannelRead's dual + // clear (read-state.ts). + clearDmUnread(channelId); } /** diff --git a/Client/tauri-client/src/lib/credentials.ts b/Client/tauri-client/src/lib/credentials.ts index 5fb47e80..64399281 100644 --- a/Client/tauri-client/src/lib/credentials.ts +++ b/Client/tauri-client/src/lib/credentials.ts @@ -4,6 +4,7 @@ */ import { createLogger } from "./logger"; +import { authStore } from "@stores/auth.store"; const log = createLogger("credentials"); @@ -50,6 +51,31 @@ export async function saveCredential( } } +/** + * Build a `user_update` listener that refreshes a session's stored + * credential when the local user's own profile changes (a username edit, or + * the identity-key PATCH) — mirroring the initial saveCredential call's + * remember-password opt-out (BUG-135) so a later profile edit can't silently + * persist a bearer token the user declined to store. Passes the session's + * password through on every call: save_credential replaces the whole stored + * blob, so omitting it (defaulting to null) would wipe out the password + * saved at login for a user who DID opt in. + */ +export function createUserUpdateCredentialSaver( + host: string, + rememberPassword: boolean, + password: string | undefined, +): (payload: { readonly user_id: number; readonly username: string }) => void { + return (payload) => { + if (!rememberPassword) return; + const currentUserId = authStore.getState().user?.id ?? 0; + if (payload.user_id !== currentUserId) return; + const currentToken = authStore.getState().token; + if (!currentToken) return; + void saveCredential(host, payload.username, currentToken, password); + }; +} + /** * Load a credential from Windows Credential Manager. * Returns null if not found or Tauri unavailable. diff --git a/Client/tauri-client/src/lib/deviceManager.ts b/Client/tauri-client/src/lib/deviceManager.ts index 1769fda9..b162ad82 100644 --- a/Client/tauri-client/src/lib/deviceManager.ts +++ b/Client/tauri-client/src/lib/deviceManager.ts @@ -70,11 +70,19 @@ export class DeviceManager { } private async handleDeviceChange(): Promise<void> { - if (this.room === null) return; + // Snapshot the room this attempt started for. `this.room` is a mutable + // field that a system-driven reconnect (or session teardown) can + // reassign out from under an in-flight await below — re-reading it + // after each await would apply the fallback to the wrong Room, or throw + // a null-deref that surfaces as a misleading "No audio input device + // available" error after the user already left voice (v096). + const room = this.room; + if (room === null) return; log.info("Device change detected"); try { const devices = await Room.getLocalDevices("audioinput"); + if (this.room !== room) return; const savedInput = loadPref<string>("audioInputDevice", ""); // Check if the saved input device was removed @@ -84,8 +92,10 @@ export class DeviceManager { savePref("audioInputDevice", ""); // Switch to default device try { - await this.room.localParticipant.setMicrophoneEnabled(false); - await this.room.localParticipant.setMicrophoneEnabled(true); + await room.localParticipant.setMicrophoneEnabled(false); + if (this.room !== room) return; + await room.localParticipant.setMicrophoneEnabled(true); + if (this.room !== room) return; try { this.audioPipeline?.setupAudioPipeline(); } catch (pipelineErr) { @@ -94,6 +104,7 @@ export class DeviceManager { } this.onToast?.("Audio device disconnected — switched to default"); } catch (err) { + if (this.room !== room) return; log.error("Failed to fallback to default input device", err); this.onErrorCallback?.("No audio input device available"); } @@ -101,6 +112,7 @@ export class DeviceManager { // Check output device const outputDevices = await Room.getLocalDevices("audiooutput"); + if (this.room !== room) return; const savedOutput = loadPref<string>("audioOutputDevice", ""); if (savedOutput !== "" && !outputDevices.some((d) => d.deviceId === savedOutput)) { log.warn("Saved audio output device removed — falling back to default", { savedOutput }); @@ -113,17 +125,20 @@ export class DeviceManager { } async switchInputDevice(deviceId: string): Promise<void> { - if (this.room === null) { + const room = this.room; + if (room === null) { log.debug("Skipping input device switch — no active voice session"); return; } try { if (deviceId) { - await this.room.switchActiveDevice("audioinput", deviceId); + await room.switchActiveDevice("audioinput", deviceId); } else { - await this.room.localParticipant.setMicrophoneEnabled(false); - await this.room.localParticipant.setMicrophoneEnabled(true); + await room.localParticipant.setMicrophoneEnabled(false); + if (this.room !== room) return; + await room.localParticipant.setMicrophoneEnabled(true); } + if (this.room !== room) return; // Rebuild audio pipeline (source track changed after device switch) try { this.audioPipeline?.setupAudioPipeline(); @@ -140,13 +155,15 @@ export class DeviceManager { } log.info("Switched input device", { deviceId }); } catch (err) { + if (this.room !== room) return; log.error("Failed to switch input device", err); this.onErrorCallback?.("Failed to switch microphone"); } } async switchOutputDevice(deviceId: string): Promise<void> { - if (this.room === null) { + const room = this.room; + if (room === null) { log.debug("Skipping output device switch — no active voice session"); return; } @@ -155,9 +172,11 @@ export class DeviceManager { // so an unhandled rejection would leave the user staring at a selection // that never took effect. try { - await this.room.switchActiveDevice("audiooutput", deviceId); + await room.switchActiveDevice("audiooutput", deviceId); + if (this.room !== room) return; log.info("Switched output device", { deviceId }); } catch (err) { + if (this.room !== room) return; log.error("Failed to switch output device", err); this.onErrorCallback?.("Failed to switch speaker"); } diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 57a9a05f..bc587ce0 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -3,8 +3,8 @@ // Each server message type maps to one or more store actions. import type { WsClient } from "./ws"; -import { toConnectionStatus } from "./ws"; -import { authStore, setAuth, clearAuth } from "@stores/auth.store"; +import { toConnectionStatus, setActiveChannelProvider } from "./ws"; +import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store"; import { setTransientError, setConnectionStatus } from "@stores/ui.store"; import { setChannels, @@ -23,6 +23,7 @@ import { deleteMessage, bulkDeleteMessages, updateReaction, + rollbackReaction, confirmSend, markSendFailed, messagesStore, @@ -50,10 +51,12 @@ import { dmStore, setDmChannels, addDmChannel, - removeDmChannel, + closeDmLocally, updateDmLastMessage, updateDmLastMessagePreview, + incrementDmMention, dmDisplayName, + updateDmParticipant, } from "@stores/dm.store"; import type { DmChannel } from "@stores/dm.store"; import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store"; @@ -64,6 +67,7 @@ import { invalidateReactionUsers } from "@components/message-list/reaction-toolt import { notifyIncomingMessage } from "./notifications"; import { highlightsCurrentUser } from "./mentions"; import { ensureIdentityKeyPublished } from "@lib/identity"; +import { markChannelRead } from "./read-state"; import { createLogger } from "./logger"; import { showToast } from "./toast"; import { ServerMessageType as S } from "./protocolTypes"; @@ -137,9 +141,28 @@ export function wireDispatcher( // ── Auth ────────────────────────────────────────────── + // Let the transport declare the open channel in the auth frame itself, so a + // resuming server can restore the ChannelTopic subscription during the + // handshake rather than only after the channel_focus round trip below — + // closing the window in which channel broadcasts reach nobody on this + // socket. The round trip stays as the fallback for older servers. + setActiveChannelProvider(() => channelsStore.select((s) => s.activeChannelId)); + unsubs.push(() => setActiveChannelProvider(null)); + unsubs.push( ws.on(S.AUTH_OK, (payload) => { setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd); + + // The resume path can land with no ChannelTopic subscription: the hub + // only transfers a focused channel from an old connection entry, but + // readPump's unregister deletes that entry as soon as the server + // observes the socket close — which happens well before the client's + // first reconnect attempt. Re-asserting focus here (idempotent on the + // server) covers that gap on every connect, resume included. + const activeChannelId = channelsStore.select((s) => s.activeChannelId); + if (activeChannelId !== null) { + ws.send({ type: "channel_focus", payload: { channel_id: activeChannelId } }); + } }), ); @@ -196,19 +219,51 @@ export function wireDispatcher( ); } - // Auto-select the first text channel if none is active + // Auto-select the first text channel if none is active; clear it when + // the channel this session was viewing is gone from the fresh snapshot + // (deleted, or a DM closed elsewhere while this client was offline) so + // the activeChannelId subscriber actually fires and tears down the + // stale message list/composer instead of leaving them mounted against + // a channel the server no longer recognizes. Checked against the raw + // payload (not the synthesized channelsStore row) so a still-open DM + // that was never locally synthesized this session isn't wrongly + // cleared. const currentActive = channelsStore.select((s) => s.activeChannelId); + // Set only when the branch below clears a channel that was active + // before this ready — distinct from "no channel was active", which + // must NOT mark-read whatever the auto-select branch just picked. + let activeChannelCleared = false; if (currentActive === null && payload.channels.length > 0) { const firstText = payload.channels.find((ch) => ch.type === "text"); if (firstText !== undefined) { setActiveChannel(firstText.id); } + } else if (currentActive !== null) { + const stillPresent = + payload.channels.some((ch) => ch.id === currentActive) || + (payload.dm_channels ?? []).some((dm) => dm.channel_id === currentActive); + if (!stillPresent) { + setActiveChannel(null); + activeChannelCleared = true; + } } - // Populate DM channels if present in the ready payload + // Populate DM channels from the ready payload. The server always sends + // the field, so an empty array is an authoritative "no open DMs" (all + // closed/left on another device) and must clear ghosts from dmStore — + // skipping it would let a stale DM survive every reconnect. const dmPayloads = payload.dm_channels ?? []; - if (dmPayloads.length > 0) { - setDmChannels(dmPayloads.map(mapDmPayload)); + setDmChannels(dmPayloads.map(mapDmPayload)); + + // The server's read_states go stale while a channel stays focused + // (channel_focus is sent once per mount, mark_read only from the context + // menu), so a full-ready resync restates non-zero unread/mention counts + // for the very channel the user is reading. Mark it read: this advances + // the server read state and clears the local badges, for server channels + // and DMs alike. Skipped on first connect (nothing was active yet) and + // when the block above just cleared a channel that's gone. + if (currentActive !== null && !activeChannelCleared) { + markChannelRead(currentActive); } // Refresh DM block state (channels-members-dms.md §3.2). "Being blocked" @@ -268,7 +323,21 @@ export function wireDispatcher( unsubs.push( ws.on(S.DM_CHANNEL_CLOSE, (payload) => { log.info("DM channel closed", { channelId: payload.channel_id }); - removeDmChannel(payload.channel_id); + // Delivered to a device that never ran the local close flow (closed + // from another signed-in device) — unlike the sidebar's closeOrLeaveDm, + // there is no "channel visited before this DM" to restore, so fall + // back to another open DM, else the first text channel. + closeDmLocally(payload.channel_id, () => { + const remaining = dmStore.getState().channels; + if (remaining.length > 0) { + setActiveChannel(remaining[0]!.channelId); + return; + } + const firstText = [...channelsStore.getState().channels.values()] + .filter((ch) => ch.type === "text") + .toSorted((a, b) => a.position - b.position)[0]; + setActiveChannel(firstText?.id ?? null); + }); }), ); @@ -296,15 +365,15 @@ export function wireDispatcher( // DM channel IDs are not in channelsStore (they use dmStore), so // incrementUnread is a no-op for DMs, but the own-message guard is // applied here for defence-in-depth. + const isMention = highlightsCurrentUser(payload.content, { + mentions: payload.mentions, + mentionsEveryone: payload.mentions_everyone, + }); + if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) { incrementUnread(payload.channel_id); // A mention is an unread too — the mention badge just outranks it. - if ( - highlightsCurrentUser(payload.content, { - mentions: payload.mentions, - mentionsEveryone: payload.mentions_everyone, - }) - ) { + if (isMention) { incrementMention(payload.channel_id); } } @@ -323,6 +392,12 @@ export function wireDispatcher( ); } else { updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp); + // The DM badge reads dmStore's mentionCount (mute-immune, rendered + // by DmSidebar) — incrementMention above no-ops for DM ids, which + // are absent from channelsStore. Same guards as the unread bump. + if (isMention) { + incrementDmMention(payload.channel_id); + } } } @@ -385,6 +460,10 @@ export function wireDispatcher( // store treats "field absent" as "leave the text alone", which is what // an older server's presence event means. updatePresence(payload.user_id, payload.status, payload.custom_status); + // dmStore keeps its own frozen copy of a DM partner's status for the + // sidebar row (see buildDmConversations) — membersStore alone does not + // reach it. + updateDmParticipant(payload.user_id, { status: payload.status }); }), ); @@ -414,6 +493,9 @@ export function wireDispatcher( .toSorted((a, b) => a.position - b.position); const firstTextId = sorted.length > 0 ? sorted[0]!.id : null; setActiveChannel(firstTextId); + // The redirect alone reads as the app spontaneously changing channels; + // say why (ux/channels-members-dms §1.2). + showToast("This channel was deleted", "info"); log.info("Active channel deleted, redirected", { deletedId: payload.id }); } }), @@ -446,6 +528,16 @@ export function wireDispatcher( ws.on(S.MEMBER_UPDATE, (payload) => { log.info("Member role updated", { userId: payload.user_id, role: payload.role }); updateMemberRole(payload.user_id, payload.role); + + // Keep authStore in sync when the signed-in user's own role changed — + // every permission gate (canManageChannels, canViewAuditLog, ...) reads + // authStore.user.role, not membersStore, so without this a promotion or + // demotion of the current user would leave every affordance stale until + // the socket reconnects (mirrors the USER_UPDATE self-branch below). + const me = authStore.getState().user; + if (me && payload.user_id === me.id) { + updateUser({ role: payload.role }); + } }), ); @@ -479,6 +571,17 @@ export function wireDispatcher( displayName: payload.display_name, identityPublicKey: payload.identity_public_key, }); + // Same reasoning as PRESENCE above: dmStore's copy of a DM partner's + // username/avatar/displayName is otherwise never refreshed. DmUser's + // avatar/displayName are non-nullable ("" = unset), so null (cleared) + // maps to "". display_name absent means "leave the nickname alone" — + // an older or partial payload must not blank it, exactly as + // updateMemberProfile above. + updateDmParticipant(payload.user_id, { + username: payload.username, + avatar: payload.avatar ?? "", + ...(payload.display_name === undefined ? {} : { displayName: payload.display_name ?? "" }), + }); // Update auth store if the current user changed their own profile. const currentUser = authStore.getState().user; @@ -555,13 +658,26 @@ export function wireDispatcher( unsubs.push( ws.on(S.VOICE_LEAVE, (payload) => { removeVoiceUser(payload); - // Notify E2EE state machine so key holder can rotate the room key. - void livekitSession().then(({ handleParticipantLeft }) => - handleParticipantLeft(payload.user_id), - ); - // Clear local voice state if the current user was removed (kick/disconnect) const currentUserId = authStore.getState().user?.id ?? 0; - if (payload.user_id === currentUserId) { + const isSelf = payload.user_id === currentUserId; + // A server-initiated eviction (revocation sweep, channel delete) has no + // companion teardown message — this voice_leave IS the signal that + // drives our own LiveKit/E2EE teardown, or mic publish and key material + // stay live while the UI shows not-in-voice. Guard on channel match: a + // late-arriving voice_leave for a channel we already left (and rejoined + // elsewhere) must not kill a newer join. Read the store before + // leaveVoiceChannel() below clears currentChannelId. + const shouldTeardownSession = + isSelf && voiceStore.getState().currentChannelId === payload.channel_id; + // Notify E2EE state machine so key holder can rotate the room key, and + // (when applicable) tear down the media session — both through one lazy + // import so the two effects cannot land in different ticks. + void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => { + void handleParticipantLeft(payload.user_id); + if (shouldTeardownSession) void leaveVoice(false); + }); + // Clear local voice state if the current user was removed (kick/disconnect) + if (isSelf) { leaveVoiceChannel(); } }), @@ -637,13 +753,38 @@ export function wireDispatcher( // Local transport failures (proxy not open, outbound channel full/closed): // fail the matching optimistic row exactly like a server error reply would. - // Fire-and-forget sends (typing, presence, voice) have no pendingSends entry - // and stay logged-only. + // An optimistic reaction toggle rolls back the same way. Fire-and-forget + // sends (typing, presence, voice) have no pending entry and stay logged-only. + // A connection that leaves "connected" can never deliver chat_send_ok for + // frames already handed to the transport: fail every pending optimistic + // send so its row offers retry instead of spinning forever (and the leaked + // pendingSends entries are cleared). + unsubs.push( + ws.onStateChange((state) => { + if (state !== "reconnecting" && state !== "disconnected") return; + // Snapshot the ids: markSendFailed deletes from pendingSends, so + // iterating the live Map's keys would mutate during iteration. + for (const id of Array.from(messagesStore.getState().pendingSends.keys())) { + markSendFailed(id, "OFFLINE"); + } + // Same reasoning applies to optimistic reaction toggles: a reaction + // frame already handed to a dying socket can never deliver its + // chat_send_ok/error either, so roll back every pending toggle instead + // of leaving a permanently wrong pill and a stale pendingReactions + // entry that could later consume an unrelated self-echo. + for (const id of Array.from(messagesStore.getState().pendingReactions?.keys() ?? [])) { + rollbackReaction(id); + } + }), + ); + unsubs.push( ws.onSendFailure((id, code) => { if (messagesStore.getState().pendingSends.has(id)) { markSendFailed(id, code); + return; } + rollbackReaction(id); }), ); @@ -675,11 +816,20 @@ export function wireDispatcher( chId === undefined ? undefined : dmStore.getState().channels.find((c) => c.channelId === chId); - if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true); + // Block gating is a 1:1-only rule (server exempts group DMs from + // block checks entirely — a group FORBIDDEN means something else, + // e.g. stale membership). recipient is just participants[0] for a + // group, so flagging it there would gate an unrelated 1:1 DM. + if (dm !== undefined && !dm.isGroup) setUserBlockedByThem(dm.recipient.id, true); } markSendFailed(id, payload.code); return; } + // A failed optimistic reaction toggle: the pill reverting is the + // feedback the spec asks for (ux/messaging §5) — no toast on top. + if (id !== undefined && rollbackReaction(id)) { + return; + } // Voice capacity refusals. The server owns the limits (voice_max_users / // voice_max_video) and refuses the join or the camera; the client never // pre-blocks the click, because its copy of the participant list can lag @@ -692,6 +842,10 @@ export function wireDispatcher( } if (payload.code === "VIDEO_LIMIT") { showToast(payload.message || "That voice channel has reached its video limit", "error"); + // max_video has no SFU-level enforcement — the server only refuses the + // DB write. Without this rollback the already-published camera track + // keeps streaming to everyone while voice_state says camera=false. + void livekitSession().then(({ disableCamera }) => disableCamera()); return; } if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") { diff --git a/Client/tauri-client/src/lib/icons.ts b/Client/tauri-client/src/lib/icons.ts index 4999fa16..52c0d506 100644 --- a/Client/tauri-client/src/lib/icons.ts +++ b/Client/tauri-client/src/lib/icons.ts @@ -65,6 +65,7 @@ export type IconName = | "shield" | "shield-check" | "shield-alert" + | "shield-question" | "zap"; // --------------------------------------------------------------------------- @@ -216,6 +217,7 @@ const ICON_PATHS: Record<IconName, string> = { shield: `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>`, "shield-check": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>`, "shield-alert": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>`, + "shield-question": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`, }; // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/src/lib/identity.ts b/Client/tauri-client/src/lib/identity.ts index 41a842ef..e1808b23 100644 --- a/Client/tauri-client/src/lib/identity.ts +++ b/Client/tauri-client/src/lib/identity.ts @@ -50,7 +50,19 @@ export async function saveIdentityKey(host: string, key: string): Promise<boolea } } -/** Load the identity private-key blob for a host, or null if absent/unavailable. */ +/** + * Load the identity private-key blob for a host, or null when nothing is + * stored (a clean `load_identity_key` resolution with no value). + * + * A command REJECTION is rethrown, not swallowed to null: `secret_store::get` + * on the Rust side reports `Ok(None)` only when both the keyring and the + * fallback file genuinely hold nothing, and propagates a keyring read error + * as `Err` instead. A rejection here is therefore a real, unreadable store — + * not "nothing stored". Callers (see `loadOrGenerateIdentityKeyPair`) rely on + * that distinction to abort instead of minting and publishing a fresh + * identity keypair over an existing one, which would invalidate every peer's + * TOFU pin. + */ export async function loadIdentityKey(host: string): Promise<string | null> { const invoke = await getInvoke(); if (!invoke) { @@ -60,8 +72,12 @@ export async function loadIdentityKey(host: string): Promise<string | null> { const result = await invoke("load_identity_key", { host }); return typeof result === "string" ? result : null; } catch (err) { - log.error("Failed to load identity key", { host, error: String(err) }); - return null; + log.error( + "Failed to load identity key — propagating so the caller does not treat an unreadable " + + 'store as "no key stored"', + { host, error: String(err) }, + ); + throw err; } } @@ -82,38 +98,75 @@ export async function deleteIdentityKey(host: string): Promise<boolean> { // ── Peer identity pins (identity_pins.json, TOFU) ────────────────────────── +/** + * Result of a peer identity-pin write. Mirrors IdentityPinLookup's tri-state + * split: "no-store" (non-Tauri environment, no pin store by design) and + * "failed" (a real write error, e.g. disk full / unwritable pins file) are + * both falsy under a plain boolean, but callers that display a "verified" + * state on the strength of a pin write must be able to tell them apart — + * collapsing them let a write failure be silently treated the same as the + * no-store case and still show "verified" with no pin ever persisted. + */ +export type StoreIdentityPinResult = "stored" | "no-store" | "failed"; + /** Pin a peer's identity public key (base64) under `{host}:{userId}`. */ export async function storeIdentityPin( host: string, userId: string, pin: string, -): Promise<boolean> { +): Promise<StoreIdentityPinResult> { const invoke = await getInvoke(); if (!invoke) { log.warn("Tauri not available — identity pin not stored"); - return false; + return "no-store"; } try { await invoke("store_identity_pin", { host, userId, pin }); - return true; + return "stored"; } catch (err) { log.error("Failed to store identity pin", { host, userId, error: String(err) }); - return false; + return "failed"; } } -/** Load a peer's pinned identity public key, or null if never pinned. */ -export async function getIdentityPin(host: string, userId: string): Promise<string | null> { +/** + * Result of a peer identity-pin lookup. "unpinned" is a trust statement — + * the store was read and holds nothing for this peer (TOFU first sight) — + * while "unavailable" means the store could not be read at all, so NO trust + * statement can be made. Mirrors the Rust TLS-TOFU split (tofu.rs), where + * `load_stored_fingerprint` returns `Err` distinctly from `Ok(None)`. + */ +export type IdentityPinLookup = + | { readonly status: "pinned"; readonly pin: string } + | { readonly status: "unpinned" } + | { readonly status: "unavailable" }; + +/** + * Look up a peer's pinned identity public key. + * + * A store read error is returned as "unavailable", NOT "unpinned" (DC-08, + * F3 follow-up 3): collapsing the two let a transient keyring error send a + * pinned peer down the first-sight path — silently verifying against, and + * then re-pinning, whatever key the server delivered. Callers must fail + * closed on "unavailable". In non-Tauri environments (tests, browser) there + * is no pin store by design, so the result is "unpinned" — consistent with + * every other wrapper in this module no-oping there. + */ +export async function getIdentityPin(host: string, userId: string): Promise<IdentityPinLookup> { const invoke = await getInvoke(); if (!invoke) { - return null; + return { status: "unpinned" }; } try { const result = await invoke("get_identity_pin", { host, userId }); - return typeof result === "string" ? result : null; + return typeof result === "string" ? { status: "pinned", pin: result } : { status: "unpinned" }; } catch (err) { - log.error("Failed to load identity pin", { host, userId, error: String(err) }); - return null; + log.error("Failed to load identity pin — treating as unavailable, not unpinned", { + host, + userId, + error: String(err), + }); + return { status: "unavailable" }; } } @@ -183,8 +236,18 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPai // docs/credential-storage.md), so reaching the branch below now means the // secret survived neither store. Kept because this is the failure a // resolved promise cannot express, and its only other symptom is peers - // flagging the user as a MITM after a restart. - if ((await loadIdentityKey(host)) !== blob) { + // flagging the user as a MITM after a restart. A read error here (as + // opposed to loadIdentityKey's first call above, which decides whether to + // regenerate) is treated the same as a mismatch, not rethrown — we + // already have a freshly generated keypair for this session, so there is + // nothing left to abort. + let persisted: boolean; + try { + persisted = (await loadIdentityKey(host)) === blob; + } catch { + persisted = false; + } + if (!persisted) { log.error( "Identity key did not persist — the credential store accepted the write but did not return it. " + "This session works, but peers will see a new identity (and prompt to re-verify) every restart.", diff --git a/Client/tauri-client/src/lib/livekitE2EE.ts b/Client/tauri-client/src/lib/livekitE2EE.ts index 5c7c1dde..d668bdbc 100644 --- a/Client/tauri-client/src/lib/livekitE2EE.ts +++ b/Client/tauri-client/src/lib/livekitE2EE.ts @@ -58,6 +58,11 @@ export class E2EEManager { private _identityKeyPair: CryptoKeyPair | null = null; /** True if this client is the key holder (longest-present participant). */ private _isKeyHolder = false; + /** Channel this exchange runs in, set at setupKeyExchange entry. The session + * facade publishes its channel id only once "connected", which is after the + * whole key-exchange wait — key-holder re-elections arriving in that window + * must not be dropped for lack of a channel id. */ + private _channelId: number | null = null; /** Resolver/rejector for non-key-holders waiting to receive the room key via offer. */ private _roomKeyResolver: (() => void) | null = null; private _roomKeyRejector: ((err: Error) => void) | null = null; @@ -80,6 +85,13 @@ export class E2EEManager { private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null; /** Interval between periodic key rotations (5 minutes). */ private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000; + /** Bumped every time clearState() tears down a session. An in-flight + * setupKeyExchange/reannounceForReconnect captures this before its first + * await and re-checks it before publishing to this._ecdhKeyPair — a plain + * `this._ecdhKeyPair === null` check can't see a teardown-then-restart + * that happens entirely during those awaits, since nothing is null by the + * time the abandoned attempt resumes. */ + private _sessionGeneration = 0; constructor(private deps: E2EEDeps) {} @@ -123,19 +135,79 @@ export class E2EEManager { * surfaces the "e2ee_timeout" error and leaves voice. */ async setupKeyExchange(isKeyHolder: boolean, channelId: number): Promise<boolean> { - // Generate a fresh ECDH keypair for this session. - this._ecdhKeyPair = await generateECDHKeyPair(); + // Captured before any await so a clearState() that lands anywhere below + // (before we publish this._ecdhKeyPair) can be detected even though + // nothing about our local state is null yet — see the field comment. + const myGeneration = this._sessionGeneration; + this._channelId = channelId; + // Generate a fresh ECDH keypair for this session, but keep it local and + // do NOT publish it to this._ecdhKeyPair until right before the drain + // below (after _isKeyHolder/_roomKey are ready). Until then, + // handleAnnounce's `!this._ecdhKeyPair` guard queues any announce that + // arrives concurrently instead of running it through the live path — + // where it would be stored in _peerPublicKeys but sent no offer (isKeyHolder + // /roomKey not set up yet) and then never seen by the drain either (it was + // never queued), stranding that peer until the next 5-minute rotation. + const ecdhKeyPair = await generateECDHKeyPair(); + // Superseded already? Everything below this point mutates state a newer + // session owns, so bail before the first write — clearing the live + // session's peer keys/verifications would drop every subsequent rotation + // for those peers (handleOffer's unknown-peer guard). + if (this._sessionGeneration !== myGeneration) { + log.warn("E2EE: setup superseded during keypair generation — aborting", { channelId }); + return false; + } this._peerPublicKeys.clear(); clearPeerVerifications(); - const myPubKeyBase64 = await exportPublicKey(this._ecdhKeyPair.publicKey); + const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey); // Build the signed announce up front — this loads the identity key from // the keyring once, so the added identity round-trip does NOT stack on // the non-key-holder's 10s key-exchange stall below (F3). const announcePayload = await this.buildAnnouncePayload(myPubKeyBase64); + // Same check again after the keyring round trip — the widest window of + // the three, and the next statements install OUR role and room key over + // whatever session is live now: a superseded non-holder attempt would + // clear the live holder's _isKeyHolder (silently stopping its rotations + // and its offers to new peers), and a superseded holder attempt would + // push a room key nobody else has onto the shared key provider. + if (this._sessionGeneration !== myGeneration) { + log.warn("E2EE: setup superseded before key-holder setup — aborting", { channelId }); + return false; + } + // Use server-authoritative is_key_holder from voice_token payload. this._isKeyHolder = isKeyHolder; + if (this._isKeyHolder) { + // Generate the room key BEFORE draining queued announces, so the + // drain's handleAnnounce calls hit the wrap-and-offer branch and every + // drained peer receives the fresh key immediately. Mid-call peers never + // re-announce (handleAnnounce replies with an offer, not a + // counter-announce), so the only later delivery would be the 5-minute + // rotation timer — stranding them on a dead key whenever a new key + // holder joins an ongoing call. + this._e2eeEpoch++; + this._roomKey = generateRoomKey(); + await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); + log.info("E2EE: key holder — generated room key", { channelId }); + this.startKeyRotationTimer(); + } + + // And once more after keyProvider.setKey's await: a torn-down attempt + // that resurrects this._ecdhKeyPair here would defeat the queue guard in + // handleAnnounceInner and go on to announce a dead ephemeral key over a + // live call (finding v043). + if (this._sessionGeneration !== myGeneration) { + log.warn("E2EE: setup superseded before keypair publish — aborting", { channelId }); + return false; + } + + // Publish the keypair now — right before the drain, so every announce + // that arrived during the awaits above was queued (not silently + // processed with no offer sent) and gets its offer sent below. + this._ecdhKeyPair = ecdhKeyPair; + // Drain any announces that arrived before our keypair was ready. These // are existing participants whose keys the server relayed during // voice_join sync — run them through the normal verifying receive path @@ -148,12 +220,6 @@ export class E2EEManager { } if (this._isKeyHolder) { - // We're the first participant — generate the room key. - this._e2eeEpoch++; - this._roomKey = generateRoomKey(); - await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); - log.info("E2EE: key holder — generated room key", { channelId }); - this.startKeyRotationTimer(); // Announce our (signed) key so existing participants can see us. this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload }); } else { @@ -179,12 +245,36 @@ export class E2EEManager { try { await Promise.race([roomKeyPromise, makeTimeout(10_000)]); } catch { - // First attempt timed out — re-announce and retry once. + // First attempt failed — re-announce and retry once. This also + // catches a decrypt failure in handleOfferInner (which rejects + // roomKeyPromise directly), not just a genuine timeout. if (timeoutId !== null) clearTimeout(timeoutId); + // clearState() (e.g. the user left voice) also rejects roomKeyPromise + // and, unlike a decrypt failure, nulls _ecdhKeyPair — there is nobody + // left to retry with. Stop here instead of re-announcing into a torn- + // down session and reinstalling a resolver nothing will ever call. + // Compare by identity, not just null: a torn-down-then-restarted + // session can leave this._ecdhKeyPair non-null but owned by a + // completely different (superseded) attempt — retrying would + // re-announce our dead ephemeral key over that live session and + // steal its single _roomKeyResolver slot (finding v043). + if (this._ecdhKeyPair !== ecdhKeyPair) { + log.warn("E2EE: key exchange aborted (session cleared or superseded)", { channelId }); + return false; + } log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId }); this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload }); + // roomKeyPromise may already be SETTLED (rejected) at this point — a + // decrypt failure rejects it permanently, so racing the SAME promise + // again would resolve rejected on the very next microtask instead of + // giving the retry its intended 5s window. Create a fresh promise and + // reinstall the resolver/rejector before racing again. + const retryPromise = new Promise<void>((resolve, reject) => { + this._roomKeyResolver = resolve; + this._roomKeyRejector = reject; + }); try { - await Promise.race([roomKeyPromise, makeTimeout(5_000)]); + await Promise.race([retryPromise, makeTimeout(5_000)]); } catch { log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId }); this._roomKeyResolver = null; @@ -209,14 +299,37 @@ export class E2EEManager { * fresh offer if the key was rotated during our absence. */ async reannounceForReconnect(): Promise<void> { - this._ecdhKeyPair = await generateECDHKeyPair(); - this._peerPublicKeys.clear(); - clearPeerVerifications(); + // Captured before any await so a clearState() (e.g. the user hits + // Disconnect during auto-reconnect) that lands during this method's + // awaits can be detected instead of silently resurrecting + // this._ecdhKeyPair / re-announcing for a channel we already left + // (finding v093). + const myGeneration = this._sessionGeneration; + const pair = await generateECDHKeyPair(); + if (this._sessionGeneration !== myGeneration) { + log.warn("E2EE: reconnect re-announce superseded before keypair publish — aborting"); + return; + } + this._ecdhKeyPair = pair; + // Peers' ECDH public keys and their TOFU verifications survive: they are + // unaffected by regenerating OUR pair, and ECDH still works (our new + // private key against their existing public key). Clearing them here would + // be permanent — handleAnnounce replies with an offer rather than a + // counter-announce, and the server relays stored peer keys only on + // voice_join — so handleOffer's unknown-peer guard would drop every + // subsequent rotation, stranding us on the pre-reconnect key. if (this._roomKey) { await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); } - const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey); + const reconnectPubKey = await exportPublicKey(pair.publicKey); const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey); + // Re-check ownership right before the send too: buildAnnouncePayload can + // itself await a keyring round trip, another window for clearState() (or + // a fresh setupKeyExchange) to have superseded this attempt. + if (this._ecdhKeyPair !== pair) { + log.warn("E2EE: reconnect re-announce superseded before send — discarding stray announce"); + return; + } this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce }); } @@ -293,7 +406,24 @@ export class E2EEManager { // Resolve the persisted pin FIRST — before any legacy shortcut. A server // must not be able to strip a pinned peer's published key (or swap it) to // force it back onto the legacy accept path (finding #2: TOFU pin bypass). - const pin = host ? await getIdentityPin(host, String(userId)) : null; + const lookup = host + ? await getIdentityPin(host, String(userId)) + : ({ status: "unpinned" } as const); + + // Fail closed when the pin store could not be read (DC-08): with the pin + // unknown, this peer might be pinned to a different key — proceeding down + // the first-sight path would verify against, and then RE-PIN, whatever key + // the server delivered. Reject the announce and surface the distinct + // "unknown" state; the peer stays blocked for E2EE until the store recovers. + if (lookup.status === "unavailable") { + setPeerVerification({ userId, status: "unknown", safetyNumber: null }); + log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", { + userId, + }); + return false; + } + + const pin = lookup.status === "pinned" ? lookup.pin : null; // Pinned peer whose delivered key is absent or differs from the pin — // possible server MITM. Block until the user re-pins. @@ -329,10 +459,28 @@ export class E2EEManager { return false; } - // First sight with a valid signature — pin the identity key now. + // First sight with a valid signature — pin the identity key now. A + // failed write (disk full, unwritable pins file) must not display + // "verified" with no pin ever persisted: the pin is what arms mismatch + // detection on a LATER announce, so a peer we call verified but never + // pinned can never have that check fire — the exact MITM window the pin + // exists to close. "no-store" (non-Tauri: no pin store by design) is not + // a failure and keeps the normal verified outcome below. + let pinWriteFailed = false; if (pin === null && host) { - await storeIdentityPin(host, String(userId), publishedIdentity); - log.info("E2EE: pinned peer identity key on first sight", { userId }); + const pinResult = await storeIdentityPin(host, String(userId), publishedIdentity); + if (pinResult === "failed") { + pinWriteFailed = true; + log.error("E2EE: failed to persist identity pin — marking unverified, not verified", { + userId, + }); + } else { + log.info("E2EE: pinned peer identity key on first sight", { userId }); + } + } + if (pinWriteFailed) { + setPeerVerification({ userId, status: "unverified", safetyNumber: null }); + return true; // still accept the announce — the write failure alone shouldn't block the call } const safetyNumber = await computeKeyFingerprint(identityKey); setPeerVerification({ userId, status: "verified", safetyNumber }); @@ -361,7 +509,17 @@ export class E2EEManager { log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId }); return false; } - await storeIdentityPin(host, String(userId), verifiedKey); + const result = await storeIdentityPin(host, String(userId), verifiedKey); + if (result === "failed") { + // The old pin is still on disk — do NOT clear the mismatch block. If we + // did, the UI would report the peer trusted while nothing was actually + // re-pinned, and the peer's very next announce would re-fail + // verification against the stale pin with no error ever surfaced. + log.error("E2EE: failed to persist re-pinned identity key — mismatch block kept", { + userId, + }); + return false; + } clearPeerVerification(userId); log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); return true; @@ -369,15 +527,35 @@ export class E2EEManager { // ── Client-side E2EE handlers (ECDH key exchange) ─────────────────────── + /** Serializes announce handling. Nothing chains concurrent invocations — + * dispatcher fires them unawaited and the queued-announce drain in + * setupKeyExchange is a separate, later pass — so two in-flight announces + * for the same peer could complete out of WS-delivery order, letting a + * stale announce's map write land after a fresher one and strand the peer + * on a dead key until the next rotation (finding v015). Mirrors + * _offerChain, whose identical ordering guarantee this file already + * relies on and tests; handleAnnounceInner swallows its own errors (see + * its try/catch below), so the chain cannot wedge on a failed announce. */ + private _announceChain: Promise<void> = Promise.resolve(); + /** * Handle a voice_e2ee_announce from the server — another participant has - * announced their ECDH public key. Before trusting it we verify the peer's + * announced their ECDH public key. Applied strictly in WS delivery order + * (see _announceChain). Before trusting it we verify the peer's * identity-key signature (F3 TOFU): resolve the peer's identity key (pinning * it on first sight), reject on mismatch/invalid signature, and only then * store the ECDH key + (if key holder) wrap the room key for them. Peers with * no published identity key (legacy) are accepted but marked unverified. */ - async handleAnnounce( + handleAnnounce(userId: number, publicKeyBase64: string, signatureBase64?: string): Promise<void> { + const run = this._announceChain.then(() => + this.handleAnnounceInner(userId, publicKeyBase64, signatureBase64), + ); + this._announceChain = run; + return run; + } + + private async handleAnnounceInner( userId: number, publicKeyBase64: string, signatureBase64?: string, @@ -427,7 +605,26 @@ export class E2EEManager { const keypair = this._ecdhKeyPair; const currentRoomKey = this._roomKey; if (this._isKeyHolder && currentRoomKey && keypair) { + // Capture epoch before the wrap await — a rotation racing this + // announce already added the peer to _peerPublicKeys before we got + // here, so it offers them the fresh key on its own; if that + // happened, ship this pre-rotation wrap and the receiver's + // strictly-ordered _offerChain ends up on the dead key. + const epochBefore = this._e2eeEpoch; const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey); + // Discard if either the epoch advanced (a rotation landed during the + // wrap) OR the keypair no longer matches (a concurrent + // reannounceForReconnect() swapped it without bumping the epoch) — + // mirrors handleOfferInner's dual guard. An offer wrapped under an + // abandoned keypair is undecryptable by the peer (finding v101). + if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) { + log.info("E2EE: discarding stale announce-offer (epoch or keypair changed during wrap)", { + userId, + epochBefore, + epochNow: this._e2eeEpoch, + }); + return; + } this.deps.getWs()?.send({ type: "voice_e2ee_offer", payload: { target_user_id: userId, encrypted_key: encryptedKey, iv }, @@ -439,11 +636,29 @@ export class E2EEManager { } } + /** Serializes offer application. The offer payload carries no epoch or + * sequence and WebCrypto gives no cross-operation ordering guarantee, so + * two in-flight offers could complete out of order — applying the older + * key last and stranding this receiver on a dead key until the next + * rotation. Chaining applies offers strictly in WS delivery order. */ + private _offerChain: Promise<void> = Promise.resolve(); + /** * Handle a voice_e2ee_offer from the server — the key holder has sent us * the encrypted room key. Unwrap it and apply to the E2EE key provider. + * Offers are applied one at a time, in delivery order. */ - async handleOffer( + handleOffer(fromUserId: number, encryptedKeyBase64: string, ivBase64: string): Promise<void> { + // handleOfferInner never rejects (it catches internally), so the chain + // cannot wedge on a failed offer. + const run = this._offerChain.then(() => + this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64), + ); + this._offerChain = run; + return run; + } + + private async handleOfferInner( fromUserId: number, encryptedKeyBase64: string, ivBase64: string, @@ -471,8 +686,12 @@ export class E2EEManager { ivBase64, ); - if (this._e2eeEpoch !== epochBefore) { - log.info("E2EE: discarding stale offer (epoch changed during unwrap)", { + // Discard if either the epoch advanced (a rotation landed during + // unwrap) OR the keypair no longer matches (clearState() ran and a new + // session generated a fresh one — possible when the epoch is 0 in both + // the old and new session, since a non-key-holder never bumps it). + if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) { + log.info("E2EE: discarding stale offer (epoch or session keypair changed during unwrap)", { fromUserId, epochBefore, epochNow: this._e2eeEpoch, @@ -484,6 +703,21 @@ export class E2EEManager { await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); log.info("E2EE: room key received and applied", { fromUserId }); + // Accepting an offer proves the sender is the server-authoritative key + // holder (the server gates outgoing offers on IsVoiceKeyHolder), so if we + // still think we hold the key, we have been re-elected away — a lower + // userID joined. Stand down: our rotations would be rejected with + // NOT_KEY_HOLDER, but only after we applied the new key locally, leaving + // us deaf and mute until the real holder rotates again. + // handleParticipantLeft can still re-promote us later. + if (this._isKeyHolder) { + this._isKeyHolder = false; + this.clearKeyRotationTimer(); + log.info("E2EE: stood down as key holder — accepted an offer from the elected holder", { + fromUserId, + }); + } + // Resolve the pending connect promise if we were waiting for the key. if (this._roomKeyResolver) { this._roomKeyResolver(); @@ -501,6 +735,41 @@ export class E2EEManager { } } + /** + * Wrap the room key for each peer and send an offer, one at a time. Bails + * out (without sending further offers) as soon as a concurrent keypair + * swap (reannounceForReconnect) or room-key change invalidates the wrap — + * an offer wrapped under an abandoned keypair/key is undecryptable by the + * peer and would otherwise silently strand them on the stale key until the + * next rotation (finding v045). Shared by the become-holder distribution, + * its late-arrival (H3) pass, and the periodic rotation loop. + */ + private async distributeRoomKey( + keypair: CryptoKeyPair, + roomKey: Uint8Array, + peers: Iterable<[number, CryptoKey]>, + ): Promise<void> { + for (const [peerId, peerKey] of peers) { + if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) { + log.warn("E2EE: aborting key distribution — keypair/room key changed mid-loop", { + peerId, + }); + return; + } + const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey); + if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) { + log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", { + peerId, + }); + return; + } + this.deps.getWs()?.send({ + type: "voice_e2ee_offer", + payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, + }); + } + } + /** * Handle a participant leaving the voice channel. If we become the new key * holder, rotate the room key and distribute to remaining peers. If we are @@ -517,7 +786,7 @@ export class E2EEManager { this._peerPublicKeys.delete(userId); clearPeerVerification(userId); - const channelId = this.deps.getCurrentChannelId(); + const channelId = this._channelId ?? this.deps.getCurrentChannelId(); if (!channelId) return; const state = voiceStore.getState(); @@ -534,9 +803,20 @@ export class E2EEManager { const myUserId = authStore.getState().user?.id ?? 0; if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) { - // Prevent concurrent rotations (e.g. two participants leave in rapid succession). + // A rotation is already in flight (e.g. we stood down mid-rotation + // after accepting another holder's offer, and are now re-elected + // because THEY left). Don't drop the re-election — that would strand + // the room with no key holder until the next voice_leave self-heals + // it. Mirror the sibling branch below: defer, don't drop. The + // in-flight rotation's finally -> drainPendingRotationOrArmTimer will + // run rotateKeyPeriodically as holder once it completes. if (this._rotatingKey) { - log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId }); + this._isKeyHolder = true; + this._rotationPending = true; + log.warn("E2EE: key rotation already in progress — deferring re-election as holder", { + userId, + channelId, + }); return; } this._rotatingKey = true; @@ -550,43 +830,39 @@ export class E2EEManager { await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); log.info("E2EE: rotated room key", { channelId, epoch: this._e2eeEpoch }); - // Snapshot peers before async loop — new peers that arrive during - // wrapping are handled by the post-rotation check below. + // A client elected while still waiting inside setupKeyExchange has a + // pending resolver — the offer it is waiting for will never arrive + // (we are the holder now), so unblock it with the key just generated. + if (this._roomKeyResolver) { + this._roomKeyResolver(); + this._roomKeyResolver = null; + this._roomKeyRejector = null; + } + + // Snapshot peers (and the keypair/room key) before the async loop — + // new peers that arrive during wrapping are handled by the + // post-rotation check below. const keypair = this._ecdhKeyPair; + const roomKey = this._roomKey; const peersSnapshot = new Map(this._peerPublicKeys); - if (keypair) { - for (const [peerId, peerKey] of peersSnapshot) { - const { encryptedKey, iv } = await wrapRoomKey( - keypair.privateKey, - peerKey, - this._roomKey, - ); - this.deps.getWs()?.send({ - type: "voice_e2ee_offer", - payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, - }); - } + if (keypair && roomKey) { + await this.distributeRoomKey(keypair, roomKey, peersSnapshot); log.info("E2EE: distributed rotated key to peers", { peerCount: peersSnapshot.size, }); // H3: Check for peers that arrived during the rotation loop and // send them the new key too. - if (keypair === this._ecdhKeyPair && this._roomKey) { - for (const [peerId, peerKey] of this._peerPublicKeys) { - if (!peersSnapshot.has(peerId)) { - const { encryptedKey, iv } = await wrapRoomKey( - keypair.privateKey, - peerKey, - this._roomKey, - ); - this.deps.getWs()?.send({ - type: "voice_e2ee_offer", - payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, - }); - log.info("E2EE: sent rotated key to late-arriving peer", { peerId }); - } + if (keypair === this._ecdhKeyPair && this._roomKey === roomKey) { + const lateArrivals = [...this._peerPublicKeys].filter( + ([peerId]) => !peersSnapshot.has(peerId), + ); + if (lateArrivals.length > 0) { + await this.distributeRoomKey(keypair, roomKey, lateArrivals); + log.info("E2EE: sent rotated key to late-arriving peers", { + peerCount: lateArrivals.length, + }); } } } @@ -642,7 +918,7 @@ export class E2EEManager { /** Rotate the room key on a timer tick (forward secrecy improvement). */ async rotateKeyPeriodically(): Promise<void> { if (!this._isKeyHolder || this._rotatingKey) return; - const channelId = this.deps.getCurrentChannelId(); + const channelId = this._channelId ?? this.deps.getCurrentChannelId(); if (!channelId) return; this._rotatingKey = true; @@ -653,21 +929,14 @@ export class E2EEManager { log.info("E2EE: periodic key rotation", { channelId, epoch: this._e2eeEpoch }); const keypair = this._ecdhKeyPair; - if (keypair && this._roomKey) { - for (const [peerId, peerKey] of this._peerPublicKeys) { - const { encryptedKey, iv } = await wrapRoomKey( - keypair.privateKey, - peerKey, - this._roomKey, - ); - this.deps.getWs()?.send({ - type: "voice_e2ee_offer", - payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, - }); - } - log.info("E2EE: distributed periodically rotated key", { - peerCount: this._peerPublicKeys.size, - }); + const roomKey = this._roomKey; + if (keypair && roomKey) { + const peerCount = this._peerPublicKeys.size; + // Pass the live map (not a snapshot): peers that arrive mid-loop are + // still visited, matching the original behavior — only the + // keypair/room-key ownership check is new here. + await this.distributeRoomKey(keypair, roomKey, this._peerPublicKeys); + log.info("E2EE: distributed periodically rotated key", { peerCount }); } } catch (err) { log.error("E2EE: periodic key rotation failed", err); @@ -696,6 +965,10 @@ export class E2EEManager { * keypair is intentionally NOT cleared here — it persists across calls to * the same host (cleared only on host change / cleanupAll). */ clearState(): void { + this._sessionGeneration++; + this._channelId = null; + this._offerChain = Promise.resolve(); + this._announceChain = Promise.resolve(); this._ecdhKeyPair = null; this._roomKey = null; this._peerPublicKeys.clear(); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 74594762..edbc5d06 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -7,6 +7,8 @@ import { setLocalDeafened, setLocalCamera, setLocalScreenshare, + setPttGated, + isPttPollingLive, leaveVoiceChannel, setListenOnly, setVoiceStatus, @@ -50,6 +52,13 @@ export type { StreamQuality } from "@lib/screenShare"; const log = createLogger("livekitSession"); +// --- Push-to-talk liveness (cross-module signal, no instance state) --- + +/** Re-exported from the voice store, which owns the flag so `ptt.ts` can write + * it at startup without importing this module (and the ~1.3 MB livekit-client + * SDK behind it). See `voice.store.ts` for the platform-capability contract. */ +export { setPttPollingLive } from "@stores/voice.store"; + // --- Pure helpers (no instance state) --- /** Parse userId from LiveKit participant identity "user-{id}" or "user-{id}:{token}". Returns 0 if unparseable. */ @@ -110,6 +119,16 @@ export class LiveKitSession { /** Single source of truth for all connection-lifecycle state. */ private _state: SessionState = { type: "idle" }; + /** BUG-142 fix: the ONLY source of join generations. Must never be + * re-derived from `_state` — a transition through "idle" (e.g. leaveVoice() + * during an in-flight connect) would reset a derived counter back to the + * same value a still-running stale attempt is holding, letting the two + * attempts collide on one generation and defeating every supersession + * checkpoint. Monotonic increments here guarantee every connectAndSetup() + * call gets a value no other attempt has ever held, regardless of how + * many times the session has bounced through "idle" in between. */ + private _joinGenerationCounter = 0; + // --- Non-connection fields (configuration / callbacks / infrastructure) --- private ws: WsClient | null = null; private onErrorCallback: ((message: string) => void) | null = null; @@ -280,6 +299,7 @@ export class LiveKitSession { getOnRemoteVideoRemovedCallback: () => this.onRemoteVideoRemovedCallback, getOnErrorCallback: () => this.onErrorCallback, isConnecting: () => this._connecting, + isReconnecting: () => this._state.type === "reconnecting", getLatestToken: () => this._latestToken, getLastUrl: () => this._lastUrl, getLastDirectUrl: () => this._lastDirectUrl, @@ -309,6 +329,21 @@ export class LiveKitSession { teardownForReconnect: () => { this._audioPipeline.teardownAudioPipeline(); this.clearTokenRefreshTimer(); + // The WS session is independent of the LiveKit drop, so tell the + // server the camera/screenshare are off before the local tracks are + // stopped below — otherwise a successful reconnect leaves the + // server's voice_states row at camera=1/screenshare=1 forever (no + // webhook clears a reconnected, non-rogue participant), occupying a + // max_video slot the user can never free. + const { localCamera, localScreenshare } = voiceStore.getState(); + if (this.ws !== null) { + if (localCamera) { + this.ws.send({ type: "voice_camera", payload: { enabled: false } }); + } + if (localScreenshare) { + this.ws.send({ type: "voice_screenshare", payload: { enabled: false } }); + } + } // BUG-098: Stop leaked camera/screen tracks before room is nulled. stopManualCameraTrack(this._cameraState, this._room); stopManualScreenTracks(this._screenState, this._room); @@ -333,7 +368,18 @@ export class LiveKitSession { // --- Room factory --- + /** The current room's E2EE worker. livekit never terminates it, so the + * session must — a leaked worker keeps receiving every future room key + * through the process-lifetime key provider's setKey fan-out. */ + private _e2eeWorker: Worker | null = null; + private createRoom(): Room { + // livekit's per-room E2EEManager registers a SetKey listener on the + // shared key provider and never removes it; only those managers + // subscribe, so clear them all before the new Room re-registers. + this._e2ee.keyProvider.removeAllListeners(); + this._e2eeWorker?.terminate(); + this._e2eeWorker = new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)); const quality = getStreamQuality(); const isSource = quality === "source"; const newRoom = new Room({ @@ -363,7 +409,7 @@ export class LiveKitSession { // per-channel symmetric key. The SFU only sees encrypted frames. e2ee: { keyProvider: this._e2ee.keyProvider, - worker: new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)), + worker: this._e2eeWorker, }, }); newRoom.on(RoomEvent.TrackSubscribed, this._eventHandlers.handleTrackSubscribed); @@ -415,8 +461,12 @@ export class LiveKitSession { log.info("Auto-reconnect aborted — user left or channel changed"); return; } + // Aliased outside the try so the catch can tear down the attempt's own + // room: this._room is null while state is "reconnecting". + let attemptRoom: Room | null = null; try { const newRoom = this.createRoom(); + attemptRoom = newRoom; const cleanupAbortedReconnect = async (): Promise<void> => { newRoom.removeAllListeners(); try { @@ -424,10 +474,14 @@ export class LiveKitSession { } catch (disconnectErr) { log.warn("Failed to disconnect room after reconnect abort", disconnectErr); } - this._audioPipeline.setRoom(null); - this._audioElements.setRoom(null); - this._deviceManager.setRoom(null); - this._deviceManager.setAudioPipeline(null); + // Re-sync from the CURRENT shared state instead of unconditionally + // nulling: by the time this runs, a newer attempt may already own + // `_state` (and its room), and this attempt's own room is never the + // one referenced there (we are aborting before reaching "connected"). + // syncModuleRooms() derives from `_room`, so it correctly nulls the + // modules when nothing newer has connected yet, and correctly leaves + // a newer session's wiring alone when one has. + this.syncModuleRooms(); }; // Set state to reconnecting with the fresh room-less attempt info; // the actual room appears in "connected" state after connect succeeds. @@ -518,10 +572,13 @@ export class LiveKitSession { return; } catch (err) { log.warn("Auto-reconnect failed", { attempt, url, error: err }); - const failedRoom = this._room; - if (failedRoom !== null) { - failedRoom.removeAllListeners(); - failedRoom + // Tear down this attempt's room (this._room is null in "reconnecting" + // state) — a leaked room keeps its listeners, and its synchronous + // Disconnected event would spawn a second, uncancellable reconnect + // loop. null only if createRoom() itself threw. + if (attemptRoom !== null) { + attemptRoom.removeAllListeners(); + attemptRoom .disconnect() .catch((disconnectErr) => log.warn("Failed to disconnect room after reconnect failure", disconnectErr), @@ -538,13 +595,31 @@ export class LiveKitSession { ac: this._state.ac, }); } - this._audioPipeline.setRoom(null); - this._audioElements.setRoom(null); - this._deviceManager.setRoom(null); - this._deviceManager.setAudioPipeline(null); + // See the matching comment in cleanupAbortedReconnect above: sync from + // the current shared state rather than unconditionally nulling, so a + // stale failed attempt cannot clobber a newer session's module wiring. + this.syncModuleRooms(); } } - // All attempts exhausted — give up and clean up. + // All attempts exhausted — give up and clean up. But first check this + // loop is still current: the user may have left voice or joined a + // different channel during the last attempt's delay/connect, in which + // case `leaveVoice(true)` below would tear down the LIVE session that + // replaced this one (CLAUDE.md: voice sessions are superseded, not + // cancelled — cleanup here must be scoped to this attempt, not global). + // The state-type check is what catches a re-join of the SAME channel: + // connectAndSetup() overwrites `_state` without aborting our signal (the + // `_room` getter is null while "reconnecting", so its entry-point + // leaveVoice(false) never runs), leaving both `signal.aborted` false and + // `_currentChannelId` equal to ours once that join reaches "connected". + if ( + signal.aborted || + this._state.type !== "reconnecting" || + this._currentChannelId !== channelId + ) { + log.info("Auto-reconnect give-up skipped — superseded"); + return; + } // Send voice_leave over WS so the server removes our voice state; // without this the server and other clients see us as a ghost participant. log.error("Auto-reconnect exhausted all attempts, giving up"); @@ -589,9 +664,14 @@ export class LiveKitSession { return proxyPath; } - /** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit. */ + /** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit. + * + * Always invokes start_livekit_proxy — never cache the port here. Only the + * Rust side can compare the running proxy's TOFU pin against certs.json, + * so after the user accepts a rotated cert a JS port cache would keep + * every voice rejoin tunneling into the stale pin until logout. The Rust + * reuse branch dedups unchanged host+pin, so the repeat call is cheap. */ private async ensureLiveKitProxy(): Promise<number> { - if (this.liveKitProxyPort !== null) return this.liveKitProxyPort; if (this.serverHost === null) throw new Error("no server host for LiveKit proxy"); // Ensure host:port format — default to 443 (standard HTTPS) when the // server is behind a reverse proxy. Without an explicit port, the Rust @@ -706,7 +786,23 @@ export class LiveKitSession { if (room === null) return; const state = voiceStore.getState(); - const muted = state.localMuted || state.localDeafened; + // A bound PTT key means transmission is gated by press/release, but the + // Rust poller only emits ptt-state on a state TRANSITION — an idle key + // produces no event at all, so without this the freshly published mic + // would stay hot and transmitting until the user's first press+release. + // Only arm this when the poller is confirmed live (setPttPollingLive) — + // gating on the stored key alone would close the mic permanently on + // platforms where PTT can never actually report state (macOS's + // is_key_down stub, pure-Wayland Linux with no XWayland). + // Record the gate in pttGated, NEVER in localMuted: localMuted means "the + // user muted themselves", and ptt.ts refuses to open the mic on a PTT + // press while it is set — writing it here would close the mic for the + // whole session instead of only until the first press. + const pttArmed = mode === "join" && isPttPollingLive() && loadPref<number>("pttVk", 0) !== 0; + if (mode === "join") { + setPttGated(pttArmed); + } + const muted = pttArmed || state.localMuted || state.localDeafened; const deafened = state.localDeafened; const shouldEnableMicrophone = !muted; @@ -784,6 +880,20 @@ export class LiveKitSession { this.onRemoteVideoRemovedCallback = null; } + /** Post-connect-checkpoint cleanup for a superseded connectAndSetup attempt + * (checkpoints 3-5, after this attempt already installed its room into the + * shared "connected" state). By the time one of these fires, a NEWER + * attempt may have already claimed `_state` (and torn down THIS attempt's + * room via its own entry-point leaveVoice(false)) — so this must disconnect + * only the passed-in localRoom, mirroring checkpoint 2, and must never call + * the global leaveVoice()/touch `_state`, or it tears down whichever + * session currently occupies `_state`, which now belongs to the newer + * attempt. */ + private disconnectSupersededLocalRoom(localRoom: Room): void { + localRoom.removeAllListeners(); + localRoom.disconnect().catch((err) => log.debug("Failed to disconnect superseded room", err)); + } + /** Shared connect-with-retry + post-connect setup used by both the primary * handleVoiceToken path and the pending-join drain loop. * Returns true if the room ended up connected and set up, @@ -797,12 +907,13 @@ export class LiveKitSession { isKeyHolder?: boolean, ): Promise<boolean | "superseded"> { if (this._room !== null) this.leaveVoice(false); - // Increment the generation counter and embed it into the "connecting" state. - // Any newer call to connectAndSetup() will produce a larger generation, - // making myGeneration !== currentGeneration at each checkpoint. - const prevState = this._state; - const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0; - const myGeneration = prevGeneration + 1; + // Draw the next generation from the monotonic instance counter (never + // re-derived from `_state`) and embed it into the "connecting" state. + // Any newer call to connectAndSetup() will produce a strictly larger + // generation, making myGeneration !== currentGeneration at each + // checkpoint even if this attempt's own state transitioned through + // "idle" in the meantime. + const myGeneration = ++this._joinGenerationCounter; this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration }); // "joining" = connecting to the room; the E2EE "securing" phase is set below. setVoiceStatus("joining"); @@ -841,8 +952,30 @@ export class LiveKitSession { setVoiceStatus("securing"); const keyExchangeOk = await this._e2ee.setupKeyExchange(isKeyHolder ?? false, channelId); if (!keyExchangeOk) { + // setupKeyExchange() also returns false when clearState() aborted the + // wait (e.g. a supersession that ran leaveVoice() while we were + // blocked here) — indistinguishable from a genuine timeout by return + // value alone. Check ownership before treating it as a real failure: + // a superseded attempt must not fire a spurious toast, send + // voice_leave (it carries no channel id and would act on whichever + // channel the NEWER attempt just joined), or clear the store's + // currentChannelId that the newer join just set. + if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) { + log.info("connectAndSetup: superseded during key exchange — aborting", { + channelId, + myGeneration, + }); + return "superseded"; + } this.onErrorCallback?.("e2ee_timeout"); - this.leaveVoice(false); + // The exchange timed out BEFORE room.connect(): no SFU participant + // exists, so no LiveKit webhook will ever clean up, and the server + // registered the join when it sent voice_token. Send voice_leave and + // leave the store's voice channel (like the reconnect-exhausted give-up + // path) or the stale row ghosts forever and can wedge the channel's + // key-holder election. + this.leaveVoice(true); + leaveVoiceChannel(); return false; } @@ -954,7 +1087,7 @@ export class LiveKitSession { log.info("connectAndSetup: superseded after restoreLocalVoiceState — aborting", { channelId, }); - this.leaveVoice(false); + this.disconnectSupersededLocalRoom(localRoom); return "superseded"; } @@ -972,7 +1105,7 @@ export class LiveKitSession { log.info("connectAndSetup: superseded after audioinput switch — aborting", { channelId, }); - this.leaveVoice(false); + this.disconnectSupersededLocalRoom(localRoom); return "superseded"; } @@ -990,7 +1123,7 @@ export class LiveKitSession { log.info("connectAndSetup: superseded after audiooutput switch — aborting", { channelId, }); - this.leaveVoice(false); + this.disconnectSupersededLocalRoom(localRoom); return "superseded"; } @@ -1004,6 +1137,12 @@ export class LiveKitSession { } catch (err) { log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err }); if (localRoom !== null) { + // Drop this attempt's listeners BEFORE disconnecting: handleDisconnected + // acts on the shared session state, so a failed attempt's Disconnected + // event would otherwise tear down (or spawn a reconnect loop for) + // whichever session owns `_state` by then — which, when this attempt + // has been superseded, is a live one that belongs to a newer join. + localRoom.removeAllListeners(); try { void localRoom.disconnect(); } catch { @@ -1011,7 +1150,28 @@ export class LiveKitSession { } this.onErrorCallback?.("Failed to join voice — connection error"); } - this.leaveVoice(false); + // Only touch the shared session state if this attempt is still current. + // A superseded attempt must not clear a newer join's server-side voice + // membership — leaveVoice's voice_leave frame carries no channel id and + // acts on whichever channel the user currently occupies, so sending it + // here for a stale attempt would delete the NEW join's voice_states row + // — nor reset a live session back to idle (CLAUDE.md: voice sessions are + // superseded, not cancelled). + if ( + this._state.type === "connecting" && + this._state.joinGeneration === myGeneration && + this._state.pendingJoin === null + ) { + // The connect attempt failed entirely: no SFU participant was ever + // created, so no LiveKit webhook will ever clean up, and the server + // already registered the join when it sent voice_token. Send + // voice_leave and leave the store's voice channel (mirroring the + // e2ee-timeout and reconnect-exhausted give-up paths) or the stale + // voice_states row ghosts forever and can wedge the channel's + // key-holder election. + this.leaveVoice(true); + leaveVoiceChannel(); + } return false; } finally { // Only clear "connecting" back to "idle" if we are still in the connecting @@ -1138,10 +1298,15 @@ export class LiveKitSession { await room.localParticipant.setMicrophoneEnabled(true); setListenOnly(false); // BUG-103: Honor deafened state — keep mic muted if user is deafened. - const { localDeafened } = voiceStore.getState(); - if (localDeafened) { + // Also honor a moderator's server-mute the same way: a listen-only join + // publishes no audio track, so the server-side mute has nothing to act + // on and persists silently — republishing here must not hand the whole + // channel a fresh, unmuted track. (The setMuted() guard does not cover + // this direct setMicrophoneEnabled call.) + const { localDeafened, localServerMuted } = voiceStore.getState(); + if (localDeafened || localServerMuted) { await this.applyMicMuteState(true); - log.info("Microphone acquired but muted (user is deafened)"); + log.info("Microphone acquired but muted (user is deafened or server-muted)"); } else { setLocalMuted(false); log.info("Microphone permission granted — exited listen-only mode"); @@ -1182,8 +1347,11 @@ export class LiveKitSession { room.removeAllListeners(); room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err)); } - // Clear client-side E2EE state (ECDH keypair, room key, peer keys). + // Clear client-side E2EE state (ECDH keypair, room key, peer keys), and + // kill the E2EE worker so the last room key does not stay resident in it. this._e2ee.clearState(); + this._e2eeWorker?.terminate(); + this._e2eeWorker = null; // Transition to idle — atomically clears room, channelId, tokens, reconnectAc, // pendingJoin, and the joinGeneration (idle has none). Any in-flight // connectAndSetup() will detect the state type change at its next checkpoint. @@ -1211,11 +1379,31 @@ export class LiveKitSession { } setMuted(muted: boolean): void { + // A moderator-imposed mute is not ours to lift. The server only mutes the + // track SIDs that exist at mute time and the LiveKit grant still carries + // the microphone publish source, so unmuting here would publish a fresh + // track the SFU happily forwards — server-side muting relies on the client + // refusing its own unmute. The guard lives here rather than in the callers + // because push-to-talk calls straight into this method (ptt.ts), bypassing + // the voice widget's own check. Muting is always permitted. + if (!muted && voiceStore.getState().localServerMuted === true) { + log.debug("Ignoring unmute: server-muted by a moderator"); + return; + } setLocalMuted(muted); this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e)); } setDeafened(deafened: boolean): void { + // Mirror setMuted's guard: a moderator-imposed deafen is not ours to + // lift locally. Without this, undeafening while server-deafened + // resubscribes remote audio and unmutes the mic client-side even though + // the server still considers the user deafened — see setMuted() above + // for why the refusal must live in this shared entry point. + if (!deafened && voiceStore.getState().localServerDeafened === true) { + log.debug("Ignoring undeafen: server-deafened by a moderator"); + return; + } setLocalDeafened(deafened); this._audioElements.applyRemoteAudioSubscriptionState(deafened); const shouldMute = deafened || voiceStore.getState().localMuted; diff --git a/Client/tauri-client/src/lib/modalFactory.ts b/Client/tauri-client/src/lib/modalFactory.ts index e593048f..221140b1 100644 --- a/Client/tauri-client/src/lib/modalFactory.ts +++ b/Client/tauri-client/src/lib/modalFactory.ts @@ -3,12 +3,17 @@ * Creates a modal with backdrop, optional click-outside and Escape key * dismissal, and clean lifecycle management via AbortController. * + * Every factory modal carries the dialog accessibility contract (DC-13): + * role="dialog" + aria-modal on the container, focus moved into the dialog on + * open and restored on close, and a Tab-cycling focus trap (lib/a11y.ts). + * * CSS classes match the existing project convention: * - div.modal-overlay.visible (backdrop) * - div.modal (content container) */ import { createElement } from "./dom"; +import { applyDialogSemantics, focusDialog, trapFocus } from "./a11y"; export interface ModalOptions { /** The content element to place inside the modal container. */ @@ -23,6 +28,8 @@ export interface ModalOptions { readonly className?: string; /** Additional attributes on the overlay element (e.g. data-testid). */ readonly overlayAttrs?: Readonly<Record<string, string>>; + /** Accessible name for the dialog (aria-label on the .modal container). */ + readonly ariaLabel?: string; /** AbortSignal for automatic cleanup when the parent component is destroyed. */ readonly signal?: AbortSignal; } @@ -53,6 +60,7 @@ export function createModal( closeOnEscape = true, className, overlayAttrs, + ariaLabel, signal, } = options; @@ -70,16 +78,20 @@ export function createModal( // Build modal container const modalClass = className !== undefined ? `modal ${className}` : "modal"; const modal = createElement("div", { class: modalClass }); + applyDialogSemantics(modal, ariaLabel !== undefined ? { label: ariaLabel } : {}); + trapFocus(modal, ac.signal); modal.appendChild(content); overlay.appendChild(modal); let closed = false; + let restoreFocus: (() => void) | null = null; function handleClose(): void { if (closed) return; closed = true; overlay.remove(); ac.abort(); + restoreFocus?.(); if (onClose !== undefined) { onClose(); } @@ -119,6 +131,7 @@ export function createModal( if (!closed) { closed = true; overlay.remove(); + restoreFocus?.(); onClose?.(); if (!ac.signal.aborted) { ac.abort(); @@ -131,6 +144,11 @@ export function createModal( container.appendChild(overlay); + // After append: move focus into the dialog and remember where it came from. + // Callers that focus a specific control afterwards (e.g. the prompt input) + // simply override the initial target; the restore still works. + restoreFocus = focusDialog(modal); + return { overlay, modal, @@ -211,7 +229,7 @@ export function createPromptModal( content.appendChild(row); const instance = createModal( - { content, onClose: options.onClose, className: "modal-prompt" }, + { content, onClose: options.onClose, className: "modal-prompt", ariaLabel: options.title }, container, ); diff --git a/Client/tauri-client/src/lib/nsfw-gate.ts b/Client/tauri-client/src/lib/nsfw-gate.ts index 7b53404f..85bd4af2 100644 --- a/Client/tauri-client/src/lib/nsfw-gate.ts +++ b/Client/tauri-client/src/lib/nsfw-gate.ts @@ -17,8 +17,26 @@ const STORAGE_PREFIX = "owncord:nsfw-ack:"; +/** + * Server host the acknowledgements below belong to. The app is multi-server, + * a server/account switch is in-document SPA navigation (no reload, so + * sessionStorage survives it), and channel ids are per-server SQLite + * autoincrement integers — without a host component in the key, an ack for + * channel N on server A silently suppresses the gate for an unrelated + * channel N on server B. `null` (before any host is known, or in a context + * that never sets one) falls back to the original unscoped key. + */ +let currentHost: string | null = null; + +/** Point acknowledgements at a specific server. Call on connect and on + * server switch, mirroring `channel-mutes.ts`'s `setChannelMutesHost`. */ +export function setNsfwGateHost(host: string | null): void { + currentHost = host; +} + function storageKey(channelId: number): string { - return `${STORAGE_PREFIX}${channelId}`; + const suffix = currentHost === null ? `${channelId}` : `${channelId}:${currentHost}`; + return `${STORAGE_PREFIX}${suffix}`; } /** diff --git a/Client/tauri-client/src/lib/protocolTypes.ts b/Client/tauri-client/src/lib/protocolTypes.ts index e907f1a1..f526f66c 100644 --- a/Client/tauri-client/src/lib/protocolTypes.ts +++ b/Client/tauri-client/src/lib/protocolTypes.ts @@ -49,6 +49,8 @@ export const ServerMessageType = { CALL_DECLINED: "call_declined", VOICE_E2EE_ANNOUNCE: "voice_e2ee_announce", // broadcast (same string as client msg) VOICE_E2EE_OFFER: "voice_e2ee_offer", // relay (same string as client msg) + COMMAND_REPLY: "command_reply", // ephemeral plugin reply, sent only to the invoking client + PLUGIN_BROADCAST: "plugin_broadcast", // plugin channel broadcast, gated by the sender's SEND_MESSAGES } as const; export type ServerMessageTypeValue = (typeof ServerMessageType)[keyof typeof ServerMessageType]; @@ -84,6 +86,7 @@ export const ClientMessageType = { VOICE_E2EE_OFFER: "voice_e2ee_offer", CALL_RING: "call_ring", CALL_DECLINE: "call_decline", + CHAT_COMMAND: "chat_command", // plugin slash-command dispatch (Phase C) } as const; export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType]; diff --git a/Client/tauri-client/src/lib/ptt.ts b/Client/tauri-client/src/lib/ptt.ts index bd946840..ad023b61 100644 --- a/Client/tauri-client/src/lib/ptt.ts +++ b/Client/tauri-client/src/lib/ptt.ts @@ -5,7 +5,7 @@ */ import { loadPref, savePref } from "@components/settings/helpers"; -import { voiceStore } from "@stores/voice.store"; +import { voiceStore, setPttGated, setPttPollingLive } from "@stores/voice.store"; import { createLogger } from "./logger"; const log = createLogger("ptt"); @@ -13,6 +13,14 @@ const log = createLogger("ptt"); let listening = false; let pttUnsubscribe: (() => void) | null = null; +/** True when the mute currently in effect is the one a PTT release applied, + * rather than one the user asked for. livekitSession.setMuted() writes + * localMuted for every caller, so that flag alone cannot tell "the user + * muted themselves" (which a press must never lift — v006) from "the last + * release muted the mic" (which it must). Reset on init/stop so a mute that + * outlived the previous PTT binding is treated as the user's. */ +let pttOwnsMute = false; + // Well-known virtual key code names for display const VK_NAMES: ReadonlyMap<number, string> = new Map([ [0x01, "Mouse Left"], @@ -89,9 +97,22 @@ export async function initPtt(): Promise<void> { await invoke("ptt_set_key", { vkCode: vk }); await invoke("ptt_start"); + // ptt_start spawns its thread unconditionally, so a running thread is NOT + // evidence that PTT works — on macOS is_key_down is a stub and on + // pure-Wayland Linux there is no reachable display. Ask the backend what + // it can actually observe, so livekitSession only applies its join-time + // PTT mute where a press can genuinely lift it again. + const supported = await invoke<boolean>("ptt_polling_supported"); + setPttPollingLive(supported); + if (!supported) { + log.warn("PTT key polling unsupported on this platform — mic will not be gated at join"); + } + // Clean up previous listener if any pttUnsubscribe?.(); pttUnsubscribe = null; + // A mute left over from a previous binding is no longer PTT's to lift. + pttOwnsMute = false; // Listen for press/release events const unsub = await listen<boolean>("ptt-state", (event) => { @@ -99,14 +120,39 @@ export async function initPtt(): Promise<void> { const channelId = voiceStore.getState().currentChannelId; if (channelId === null) return; + const pressed = event.payload; + // Track the PTT gate in the store regardless of whether we end up + // calling setMuted below — this is the source of truth other code + // (e.g. the widget) can read without depending on localMuted. + setPttGated(!pressed); + // livekitSession (and the ~1.3 MB livekit-client SDK behind it) is // loaded lazily so it stays out of the startup path. In a voice channel // the module is necessarily already loaded, so this import resolves // from the module cache in a microtask. void import("./livekitSession") .then(({ setMuted }) => { - setMuted(!event.payload); - log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted"); + const { localMuted, localDeafened } = voiceStore.getState(); + if (pressed) { + // Never let PTT lift a mute the user asked for — that would + // republish the mic to every peer while voice_states.muted (and + // every remote UI) still shows the user muted (v006). The mute a + // previous release applied is PTT's own, so lifting that is fine. + if (localDeafened || (localMuted && !pttOwnsMute)) { + log.debug("PTT pressed — staying muted (user is self-muted or deafened)"); + return; + } + setMuted(false); + pttOwnsMute = false; + log.debug("PTT pressed — unmuted"); + return; + } + // Muting is always safe. setMuted() writes localMuted, so record + // whether this release is what muted the mic — only then may the + // next press lift it. + setMuted(true); + pttOwnsMute = !localMuted; + log.debug("PTT released — muted"); }) .catch((e) => log.warn("Failed to apply PTT mute", e)); }); @@ -115,7 +161,10 @@ export async function initPtt(): Promise<void> { listening = true; log.info("PTT started", { vk, name: vkName(vk) }); } catch (err) { - // Not in Tauri environment (dev mode) + // Not in Tauri environment (dev mode), or the backend rejected a command. + // Either way no ptt-state event can arrive, so the poller is not live — + // leaving a stale `true` here would let a later join mute the mic for good. + setPttPollingLive(false); log.debug("PTT not available", { error: err }); } } @@ -126,6 +175,10 @@ export async function stopPtt(): Promise<void> { try { pttUnsubscribe?.(); pttUnsubscribe = null; + pttOwnsMute = false; + // No further ptt-state events once the loop is torn down; clear the flag + // before the await so a concurrent join cannot observe a stale `true`. + setPttPollingLive(false); const { invoke } = await import("@tauri-apps/api/core"); await invoke("ptt_stop"); listening = false; diff --git a/Client/tauri-client/src/lib/read-state.ts b/Client/tauri-client/src/lib/read-state.ts index d8d2bc8c..0c4b72f9 100644 --- a/Client/tauri-client/src/lib/read-state.ts +++ b/Client/tauri-client/src/lib/read-state.ts @@ -24,6 +24,11 @@ let sender: MarkReadSender | null = null; */ export function setMarkReadSender(next: MarkReadSender | null): void { sender = next; + // A re-registration means a new connection (MainPage mounts once per + // session), so anything `markAllRead` still had queued belongs to the + // previous server. Channel ids are per-server, so letting those fire would + // mark the *new* server's same-numbered channel read. + cancelPendingMarkAll(); } /** @@ -66,12 +71,52 @@ export function unreadChannelIds(): readonly number[] { return [...ids]; } +/** + * The server's `mark_read` handler shares a 5-per-second-per-user budget with + * `channel_focus` (Server/ws/handlers_presence.go) and silently drops frames + * over that budget — no error reaches the client. A burst of `mark_read` + * sends larger than the budget would still clear every local badge (see + * `markChannelRead`), so the excess channels' badges would resurrect on the + * next `ready` once the server re-asserts its own unread counts. Pacing the + * burst to below the budget, with headroom for a `channel_focus` that may + * have already spent a slot, keeps every send inside a window the server + * actually honours. + */ +const MARK_ALL_READ_BURST_SIZE = 4; +const MARK_ALL_READ_BURST_INTERVAL_MS = 1100; + +/** Timers for the not-yet-sent tail of the current `markAllRead` burst. Held so + * a second mark-all, or a new connection, can drop the stale ones instead of + * letting them land against a channel list that has since been replaced. */ +let pendingMarkAll: Array<ReturnType<typeof setTimeout>> = []; + +function cancelPendingMarkAll(): void { + for (const t of pendingMarkAll) clearTimeout(t); + pendingMarkAll = []; +} + /** * Mark every unread channel and DM read. Returns how many were marked, so the * caller can stay silent when there was nothing to do. + * + * Sent in bursts of `MARK_ALL_READ_BURST_SIZE` spaced `MARK_ALL_READ_BURST_INTERVAL_MS` + * apart — see the budget note above. Each channel's local badge is cleared at + * the moment its own frame actually goes out, not up front, so a channel + * whose send hasn't fired yet still shows unread rather than lying about it. */ export function markAllRead(): number { + // A second click supersedes the first: its own `unreadChannelIds()` already + // covers everything the earlier burst had not sent yet, so keeping the old + // timers would only duplicate sends and spend budget twice. + cancelPendingMarkAll(); const ids = unreadChannelIds(); - for (const id of ids) markChannelRead(id); + for (const [i, id] of ids.entries()) { + const delay = Math.floor(i / MARK_ALL_READ_BURST_SIZE) * MARK_ALL_READ_BURST_INTERVAL_MS; + if (delay === 0) { + markChannelRead(id); + } else { + pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay)); + } + } return ids.length; } diff --git a/Client/tauri-client/src/lib/reconcile.ts b/Client/tauri-client/src/lib/reconcile.ts deleted file mode 100644 index dea8c218..00000000 --- a/Client/tauri-client/src/lib/reconcile.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * DOM list reconciliation utility. - * Efficiently patches a container's children to match a new list of items, - * preserving existing DOM elements where possible (no nuke-and-rebuild). - * - * Algorithm: - * 1. Build a map of existing elements by key - * 2. Walk the new items list: - * - If key exists in map → update in place, move to correct position - * - If key is new → create element, insert at correct position - * 3. Remove any elements whose keys are no longer in the list - * - * This preserves hover states, focus, CSS transitions, and scroll position. - */ - -export interface ReconcileOptions<T> { - /** The container element whose children will be patched. */ - readonly container: Element; - /** The new list of items to render. */ - readonly items: readonly T[]; - /** Extract a unique string key from each item. */ - readonly key: (item: T) => string; - /** Create a new DOM element for an item. */ - readonly create: (item: T) => Element; - /** Update an existing DOM element with new item data. Return the element. */ - readonly update: (el: Element, item: T) => void; -} - -/** - * Reconcile a container's children against a list of keyed items. - * Preserves existing DOM elements, only adding/removing/reordering as needed. - */ -export function reconcileList<T>(opts: ReconcileOptions<T>): void { - const { container, items, key, create, update } = opts; - - // Build map of existing children by data-key attribute - const existingByKey = new Map<string, Element>(); - for (let i = container.children.length - 1; i >= 0; i--) { - const child = container.children[i]!; - const k = child.getAttribute("data-reconcile-key"); - if (k !== null) { - existingByKey.set(k, child); - } - } - - const newKeys = new Set<string>(); - - // Walk new items, create/update/reorder - for (let i = 0; i < items.length; i++) { - const item = items[i]!; - const k = key(item); - newKeys.add(k); - - let el = existingByKey.get(k); - if (el !== undefined) { - // Update existing element - update(el, item); - } else { - // Create new element - el = create(item); - el.setAttribute("data-reconcile-key", k); - } - - // Move/insert to correct position - const currentAtPosition = container.children[i]; - if (currentAtPosition !== el) { - container.insertBefore(el, currentAtPosition ?? null); - } - } - - // Remove elements whose keys are no longer in the list - for (const [k, el] of existingByKey) { - if (!newKeys.has(k)) { - el.remove(); - } - } -} diff --git a/Client/tauri-client/src/lib/roomEventHandlers.ts b/Client/tauri-client/src/lib/roomEventHandlers.ts index 51e2307a..c2c3f103 100644 --- a/Client/tauri-client/src/lib/roomEventHandlers.ts +++ b/Client/tauri-client/src/lib/roomEventHandlers.ts @@ -31,6 +31,7 @@ export interface RoomEventDeps { getOnRemoteVideoRemovedCallback: () => RemoteVideoRemovedCallback | null; getOnErrorCallback: () => ((message: string) => void) | null; isConnecting: () => boolean; + isReconnecting: () => boolean; getLatestToken: () => string | null; getLastUrl: () => string | null; getLastDirectUrl: () => string | undefined; @@ -159,8 +160,13 @@ export function createRoomEventHandlers(deps: RoomEventDeps): RoomEventHandlers const handleDisconnected = (reason?: DisconnectReason): void => { log.info("LiveKit room disconnected", { reason }); - if (deps.isConnecting()) { - log.info("Disconnect during initial connect — deferring to retry loop"); + if (deps.isConnecting() || deps.isReconnecting()) { + // The bundled livekit-client fires this event synchronously on every + // failed reconnect attempt inside the retry loop's own room.connect() + // call, before that call rejects — the active loop already owns retry + // and cleanup, so a second entry here must not start a second, + // uncancellable attemptAutoReconnect loop (mirrors the initial-connect guard above). + log.info("Disconnect during connect/reconnect — deferring to retry loop"); return; } const isUnexpected = reason !== DisconnectReason.CLIENT_INITIATED; diff --git a/Client/tauri-client/src/lib/streamPreview.ts b/Client/tauri-client/src/lib/streamPreview.ts index 561e4281..cf558df7 100644 --- a/Client/tauri-client/src/lib/streamPreview.ts +++ b/Client/tauri-client/src/lib/streamPreview.ts @@ -23,6 +23,54 @@ interface PreviewState { const previewTimers = new WeakMap<HTMLElement, PreviewState>(); +/** + * Rows currently attached to each sidebar-lifetime AbortSignal, keyed by the + * user the row belongs to. One abort listener is registered per signal + * (below), not one per attachStreamPreview call — the sidebar re-renders on + * every structural voice change and re-attaches every previewable row to the + * same signal each time, so a per-call listener would accumulate one closure + * (pinning its row) forever. + * + * Keying by user id is what bounds the map: renderChannels() does a full + * clearChildren + rebuild, so a user's fresh row supersedes the detached one + * it replaces instead of stranding it here for the sidebar's lifetime (v091). + * Identity, not `isConnected`, decides that — ChannelSidebar attaches previews + * while the rebuilt rows are still in a detached subtree, so a liveness test + * at attach time would discard rows that are about to be inserted. + */ +const rowsBySignal = new WeakMap<AbortSignal, Map<number, HTMLElement>>(); + +/** Track `row` against `signal`, registering the signal's shared abort + * listener the first time it's seen and retiring the row this one replaces. */ +function trackRowForSignal(row: HTMLElement, userId: number, signal: AbortSignal): void { + let rows = rowsBySignal.get(signal); + if (rows === undefined) { + rows = new Map(); + rowsBySignal.set(signal, rows); + signal.addEventListener( + "abort", + () => { + for (const trackedRow of rows!.values()) { + clearPreviewState(trackedRow); + removePreviewDom(trackedRow); + } + rows!.clear(); + }, + { once: true }, + ); + } + const superseded = rows.get(userId); + if (superseded !== undefined && superseded !== row) { + // The previous render's row for this user was discarded by + // renderChannels(); run the cleanup the abort handler would have run on + // it (debounce/animation timers plus any live MediaStreamTrack listeners + // an open preview registered) now that a replacement proves it is dead. + clearPreviewState(superseded); + removePreviewDom(superseded); + } + rows.set(userId, row); +} + /** Height the preview expands to. Set dynamically after DOM insertion. */ /** Debounce delay before showing the preview. */ const DEBOUNCE_MS = 300; @@ -289,11 +337,8 @@ export function attachStreamPreview( row.addEventListener("focusin", startPreview, { signal }); row.addEventListener("focusout", stopPreview, { signal }); - // Cleanup on abort (sidebar teardown) - signal.addEventListener("abort", () => { - clearPreviewState(row); - removePreviewDom(row); - }); + // Cleanup on abort (sidebar teardown) — one shared listener per signal. + trackRowForSignal(row, userId, signal); } /** diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 3f4efd7a..bbd61c97 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -1,7 +1,7 @@ // ============================================================================= // OwnCord Protocol Types // All WebSocket message types, REST response types, and permission definitions. -// Source of truth: PROTOCOL.md, API.md, SCHEMA.md +// Source of truth: docs/protocol.md, docs/api.md, docs/schema.md // ============================================================================= // ----------------------------------------------------------------------------- @@ -189,6 +189,9 @@ export interface ReadyVoiceState { /** Moderator-imposed; optional so an older server's payload still parses. */ readonly server_muted?: boolean; readonly server_deafened?: boolean; + /** Live video publications; optional so an older server's payload still parses. */ + readonly camera?: boolean; + readonly screenshare?: boolean; } /** Role object in the ready payload and in roles_update. */ @@ -351,6 +354,16 @@ export interface ChannelCreatePayload { /** Voice capacity limits (0 = unlimited). See ReadyChannel. */ readonly voice_max_users?: number; readonly voice_max_video?: number; + /** + * This viewer's composer affordance — see ReadyChannel.can_send. + * + * Present only on the per-client channel_create the server sends when a + * role or override edit changes who may post (RefreshChannelVisibility); + * absent on the shared-buffer broadcast, which encodes one frame for many + * recipients, and absent from older servers. Treat absent as "unchanged", + * never as false. + */ + readonly can_send?: boolean; } export interface ChannelUpdatePayload { @@ -587,6 +600,18 @@ export interface ErrorPayload { export interface AuthPayload { readonly token: string; readonly last_seq?: number; + /** + * The channel this client had open when it disconnected, sent only on a + * resume (`last_seq > 0`). + * + * Lets the server restore the ChannelTopic subscription during the handshake + * instead of leaving the socket unsubscribed until the post-`auth_ok` + * `channel_focus` round trip lands — messages broadcast in that window would + * otherwise reach nobody on this connection and could never be re-requested, + * since the client only reports `max(seq)`. The server re-checks read + * permission before honouring it. Omitted when unknown. + */ + readonly active_channel_id?: number; } export interface ChatSendPayload { @@ -904,16 +929,6 @@ export interface EmojiResponse { readonly url: string; } -/** Single sound object from GET /api/sounds. */ -export interface SoundResponse { - readonly id: number; - readonly name: string; - readonly filename: string; - readonly duration_ms: number; - readonly uploaded_by: number; - readonly created_at: string; -} - /** Single invite object from GET/POST /api/invites. */ export interface InviteResponse { readonly id: number; @@ -924,16 +939,6 @@ export interface InviteResponse { readonly expires_at: string | null; } -/** Single session object from GET /api/users/me/sessions. */ -export interface SessionResponse { - readonly id: number; - readonly device: string | null; - readonly ip_address: string | null; - readonly created_at: string; - readonly last_used: string; - readonly expires_at: string; -} - /** Upload response from POST /api/uploads. */ export interface UploadResponse { readonly id: string; diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index a3bc31e2..10eeba4a 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -88,6 +88,27 @@ export interface WsClientConfig { readonly maxMessageSizeBytes?: number; } +/** + * Supplies the channel the user currently has open, so the auth frame can + * declare it on a resume. + * + * Registered rather than imported: ws.ts is the transport and deliberately + * depends on nothing but types and the logger, which is what lets the tests + * drive it with minimal mocks. + * + * Without this the resumed connection holds no ChannelTopic subscription until + * the post-auth_ok `channel_focus` round trip completes, and every message + * broadcast to that channel in the meantime is lost with no way to ask for it + * back (the client only reports max(seq)). The server still READ-gates the id + * before honouring it, and `channel_focus` is still sent on auth_ok — this + * only shrinks the window to zero. + */ +let activeChannelProvider: (() => number | null) | null = null; + +export function setActiveChannelProvider(fn: (() => number | null) | null): void { + activeChannelProvider = fn; +} + const DEFAULT_MAX_RECONNECT_DELAY = 30_000; const DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; // 1MB const HEARTBEAT_INTERVAL_MS = 30_000; @@ -96,6 +117,15 @@ function uuid(): string { return crypto.randomUUID(); } +/** Normalize a host for comparison against the Rust proxies' cert-tofu event + * host, mirroring `tofu::cert_store_key`'s trailing-":443" strip + * (src-tauri/src/tofu.rs). Profile/config hosts are stored verbatim (e.g. + * "example.com:443"), but the proxies always emit the normalized form, so an + * un-normalized comparison here would silently miss the match. */ +function normalizeHostForCertCompare(host: string): string { + return host.replace(/:443$/, ""); +} + export function createWsClient() { let config: WsClientConfig | null = null; let state: ConnectionState = "disconnected"; @@ -329,8 +359,22 @@ export function createWsClient() { fingerprint: evt.fingerprint, storedFingerprint: evt.storedFingerprint, }); - certMismatchBlock = true; - setState("disconnected"); + // Only latch/tear down THIS connection when the mismatch is for the + // host it's actually connected to — the http proxy emits mismatch + // events for any tunneled host, and the connect page health-checks + // every saved profile, so an unrelated profile's rotated cert must not + // permanently kill this socket's reconnect loop. + if (config !== null && raw.host === normalizeHostForCertCompare(config.host)) { + certMismatchBlock = true; + // A reconnect armed before the mismatch arrived would still fire and + // call connect(), which clears the latch — resuming the loop against + // the very host whose certificate just changed. Latching only blocks + // FUTURE scheduling, so the pending attempt has to be cancelled here. + cancelReconnect(); + setState("disconnected"); + } + // Notified unconditionally either way — the connect page's first-use + // and mismatch modals key off host and need every event. for (const listener of certMismatchListeners) { listener(evt); } @@ -370,7 +414,19 @@ export function createWsClient() { } setState("authenticating"); if (config === null) return; - send({ type: "auth", payload: { token: config.token, last_seq: lastSeq } }); + // active_channel_id only matters on a resume (last_seq > 0); on a + // fresh connect the ready payload re-establishes everything anyway. + // Omitted when unknown so the frame stays byte-identical to before + // for callers that never register a provider. + const activeChannelId = lastSeq > 0 ? (activeChannelProvider?.() ?? null) : null; + send({ + type: "auth", + payload: { + token: config.token, + last_seq: lastSeq, + ...(activeChannelId !== null ? { active_channel_id: activeChannelId } : {}), + }, + }); } else if (rustState === "closed") { proxyOpen = false; log.info("WebSocket closed", { @@ -428,6 +484,10 @@ export function createWsClient() { wsGeneration++; config = cfg; intentionalClose = false; + // Belt-and-braces: a fresh connect (even one not routed through + // disconnect(), e.g. a suppressed-modal cert latch from an unrelated + // host) must not inherit a stale block from a previous connection. + certMismatchBlock = false; cancelReconnect(); setState("connecting"); @@ -535,6 +595,10 @@ export function createWsClient() { // (logout). Automatic reconnects go through scheduleReconnect() which // preserves lastSeq for server-side event replay. lastSeq = 0; + // Reset the backoff exponent too — a session abandoned mid-reconnect must + // not carry its attempt count (and therefore its backoff ceiling) into + // the next login's first retry. + reconnectAttempt = 0; } return { diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 1ba35558..8f1f701e 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -23,11 +23,17 @@ import { createConnectedOverlay } from "@components/ConnectedOverlay"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; import { createLogger, applyStoredLogLevel } from "@lib/logger"; import { initLogPersistence, flushLogs } from "@lib/logPersistence"; -import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials"; +import { + saveCredential, + loadCredential, + deleteCredential, + createUserUpdateCredentialSaver, +} from "@lib/credentials"; import { initWindowState } from "@lib/window-state"; import { initDeepLinks } from "@lib/deep-link"; import { jumpToMessage } from "@lib/message-navigation"; import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal"; +import { reconnectAfterCertAccept } from "@lib/cert-reconnect"; import { createProfileManager, createTauriBackend } from "@lib/profiles"; import type { CertTofuEvent } from "@lib/ws"; @@ -189,7 +195,7 @@ ws.onCertMismatch((evt: CertTofuEvent) => { try { await ws.acceptCertFingerprint(evt.host, evt.fingerprint); if (lastConnectHost && lastConnectToken) { - ws.connect({ host: lastConnectHost, token: lastConnectToken }); + reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken); } } catch (err) { log.error("Failed to accept cert fingerprint", err); @@ -324,16 +330,11 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> { } // Update saved credentials when the current user changes their username. + // Guarded by the same remember-password opt-out as the initial save + // above (BUG-135), and passes the session's password through so a later + // save doesn't wipe out the one saved at login for an opted-in user. sessionUnsubs.push( - ws.on("user_update", (payload) => { - const currentUserId = authStore.getState().user?.id ?? 0; - if (payload.user_id === currentUserId) { - const currentToken = authStore.getState().token; - if (currentToken) { - void saveCredential(host, payload.username, currentToken); - } - } - }), + ws.on("user_update", createUserUpdateCredentialSaver(host, rememberPassword, password)), ); const unsubState = ws.onStateChange((wsState) => { @@ -568,10 +569,19 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> { if (autoLoginCancelled) return; - // Use stored token directly for reconnection + // Use stored token directly for reconnection. Preserve the + // profile's existing rememberPassword rather than forcing it to + // false (autoConnect profiles always have it true — see + // setAutoLogin), and skip wirePostAuth's credential re-save: the + // password is never returned over IPC here, so saving with + // rememberPassword defaulted to true would call saveCredential + // with password undefined, which rewrites the whole stored + // blob and silently destroys any password the user opted to + // remember (save_credential only carries the password key + // `if let Some(...)`, so a None wipes it — see credentials.rs). api.setConfig({ host: autoProfile.host }); - ensureProfileExists(autoProfile.host, cred.username, false); - wirePostAuth(autoProfile.host, cred.token, cred.username); + ensureProfileExists(autoProfile.host, cred.username, autoProfile.rememberPassword); + wirePostAuth(autoProfile.host, cred.token, cred.username, undefined, false); return; } } catch (err) { @@ -608,9 +618,14 @@ authStore.subscribeSelector( (s) => s.isAuthenticated, (isAuthenticated) => { if (!isAuthenticated && router.getCurrentPage() === "main") { - // Leave voice channel before disconnecting so other clients see it immediately - const voice = voiceStore.getState(); - if (voice.currentChannelId !== null) { + // Leave voice channel before disconnecting so other clients see it + // immediately. Gated on clearAuth's logoutWasInVoice snapshot rather + // than the live voiceStore: clearAuth applies state (including this + // isAuthenticated flip) synchronously and already reset voiceStore in + // that same call, before this subscriber ever runs (store + // notifications are microtask-deferred) — voiceStore here would always + // read "idle". + if (authStore.getState().logoutWasInVoice === true) { voiceSessionLeave(false); // false: we send voice_leave below ws.send({ type: "voice_leave", payload: {} }); leaveVoiceChannel(); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 9c12a6ca..1e30d47f 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -42,6 +42,8 @@ import { clearReactionUsersCache, } from "@components/message-list/reaction-tooltip"; import { setMarkReadSender } from "@lib/read-state"; +import { setChannelMutesHost } from "@lib/channel-mutes"; +import { setNsfwGateHost } from "@lib/nsfw-gate"; import { createQuickSwitcherManager } from "./main-page/OverlayManagers"; import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds"; import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks"; @@ -94,6 +96,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent { setLiveKitServerHost(apiConfig.host); } + // Channel ids are only unique per server, so anything persisted under a bare + // channel id collides across profiles in the multi-server client. Scope both + // stores to the connected host — including the null case, so a disconnect + // cannot leave the previous server's scope armed for the next connection. + setChannelMutesHost(apiConfig.host ?? null); + setNsfwGateHost(apiConfig.host ?? null); + // "Mark as Read" affordances need the socket but are reached from deep inside // the sidebar; register the sender once instead of threading ws through. setMarkReadSender((channelId) => { @@ -686,6 +695,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent { resolveChannelName(active.id, active.name, active.type), active.type, ); + } else { + // The active channel was cleared with nothing to replace it + // (deleted, or a DM closed while offline) — without this the + // previous channel's MessageList/composer stayed mounted and + // enabled against a channel the server no longer recognizes. + closeDmProfile(); + channelCtrl?.destroyChannel(); } } catch (err) { log.error("Channel mount failed", err); diff --git a/Client/tauri-client/src/pages/connect-page/LoginForm.ts b/Client/tauri-client/src/pages/connect-page/LoginForm.ts index 293ccecd..3bb065e5 100644 --- a/Client/tauri-client/src/pages/connect-page/LoginForm.ts +++ b/Client/tauri-client/src/pages/connect-page/LoginForm.ts @@ -609,6 +609,16 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi { } async function handleTotpSubmit(): Promise<void> { + // Re-entrancy guard: the click path is protected by the button's + // disabled attribute, but the Enter-key listener on totpInput (below) is + // not — key auto-repeat or a fast double-Enter during the verify round + // trip would otherwise fire a second request with the same one-time code, + // which the server 401s (codes are single-use) and paints a spurious + // "invalid two-factor code" error over a login that already succeeded. + // The disabled flag already brackets exactly the in-flight window, so + // reusing it covers both paths with one check. + if (totpSubmitBtn.disabled) return; + const code = totpInput.value.trim(); if (code.length !== 6 || !/^\d{6}$/.test(code)) { // Simple inline feedback — add error class to the input diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 079df401..6d0582b9 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -24,6 +24,7 @@ import { markSendFailed, removeOptimistic, reattachToPresent, + isWindowDetached, } from "@stores/messages.store"; import { jumpToMessage } from "@lib/message-navigation"; import { authStore } from "@stores/auth.store"; @@ -39,6 +40,7 @@ import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store"; import { membersStore } from "@stores/members.store"; import { channelsStore, setActiveChannel } from "@stores/channels.store"; import { uiStore } from "@stores/ui.store"; +import { markChannelRead } from "@lib/read-state"; const log = createLogger("channel-ctrl"); @@ -106,6 +108,19 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // Store/ws subscriptions that keep the composer's disabled state in sync. let composerGatingUnsubs: (() => void)[] = []; + // Optimistic send: keep the raw payload per correlation id so a failed send + // can be retried (including its attachments). Controller-scoped rather than + // per-mount: correlation ids are globally unique (crypto.randomUUID), and a + // failed row survives a channel switch (messages.store carries non-"sent" + // rows across refetches), so its draft must survive the switch too — a + // per-mount map left Retry on a failed row that outlived a remount silently + // inert. Entries are dropped on retry, discard, and the chat_send_ok ack, so + // controller scope does not turn it into a session-long transcript. + const draftByCorrelation = new Map< + string, + { content: string; replyTo: number | null; attachments: readonly string[] } + >(); + function destroyChannel(): void { pendingDeleteManager.cleanup(); @@ -143,8 +158,17 @@ export function createChannelController(opts: ChannelControllerOptions): Channel function mountChannel(channelId: number, channelName: string, channelType?: ChannelType): void { if (_currentChannelId === channelId) return; + const previousChannelId = _currentChannelId; destroyChannel(); _currentChannelId = channelId; + // channel_focus (sent below) is the only thing that advances the *new* + // channel's server-side read state; leaving one never does. Without this, + // messages read while focused here restate as unread/mention badges on + // the next full `ready`. mark_read is a local no-op (already zeroed on + // open) — it only repairs the server's view. + if (previousChannelId !== null) { + markChannelRead(previousChannelId); + } log.info("Switching channel", { channelId, channelName }); @@ -157,14 +181,6 @@ export function createChannelController(opts: ChannelControllerOptions): Channel const signal = channelAbort.signal; const userId = getCurrentUserId(); - // Optimistic send: keep the raw payload per correlation id so a failed - // send can be retried (including its attachments). Channel-scoped — cleared - // when the channel unmounts. - const draftByCorrelation = new Map< - string, - { content: string; replyTo: number | null; attachments: readonly string[] } - >(); - function currentMessageUser(): MessageUser | null { const u = authStore.getState().user; if (u === null) return null; @@ -178,6 +194,16 @@ export function createChannelController(opts: ChannelControllerOptions): Channel ): void { const user = currentMessageUser(); if (user === null) return; + // Sending while viewing a detached history window jumps to present: the + // optimistic row belongs in the live tail, and addMessage would refuse + // to append the echo into a detached window anyway. Mirrors + // onJumpToPresent — reattach clears "loaded" so the tail is refetched. + if (isWindowDetached(channelId)) { + reattachToPresent(channelId); + if (channelAbort !== null) { + void msgCtrl.loadMessages(channelId, channelAbort.signal); + } + } const timestamp = new Date().toISOString(); if (uiStore.getState().connectionStatus !== "connected") { // Composer gating normally prevents this, but stay consistent: show a @@ -220,8 +246,9 @@ export function createChannelController(opts: ChannelControllerOptions): Channel currentUserId: userId, onScrollTop: () => { if (channelAbort !== null) { - void msgCtrl.loadOlderMessages(channelId, channelAbort.signal); + return msgCtrl.loadOlderMessages(channelId, channelAbort.signal); } + return undefined; }, onRetryLoad: () => { if (channelAbort !== null) { @@ -415,7 +442,14 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // The server accepted a message — the next one is subject to the cooldown. composerGatingUnsubs.push( - ws.on("chat_send_ok", () => { + ws.on("chat_send_ok", (_payload, correlationId) => { + // An accepted send can never be retried, so its draft is dead weight. + // The map is controller-scoped (a failed row outlives a channel + // switch, so its draft must too), which means nothing else would ever + // drop it — every message sent in the session would be retained. + if (correlationId !== undefined && correlationId !== "") { + draftByCorrelation.delete(correlationId); + } const ch = channelsStore.getState().channels.get(channelId); if (ch !== undefined && ch.id === channelsStore.getState().activeChannelId) { startSlowMode(ch.slowMode); @@ -500,21 +534,42 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // Update header if (chatHeaderRefs !== null && channelType === "dm") { - const dmChannel = dmStore.getState().channels.find((c) => c.channelId === channelId); - // A group has no single presence to show, so the subtitle lists who is - // in it instead — that is the fact a group header is asked for, and a - // first member's status presented as the group's would be a lie. - let subtitle = "Offline"; - if (dmChannel !== undefined && dmChannel.isGroup) { - const names = dmChannel.participants.map((p) => (p.displayName ?? "") || p.username); - subtitle = `${names.length + 1} members: You, ${names.join(", ")}`; - } else if (dmChannel !== undefined) { - const member = membersStore.getState().members.get(dmChannel.recipient.id); - const status = member?.status ?? dmChannel.recipient.status ?? "Offline"; - subtitle = status.charAt(0).toUpperCase() + status.slice(1); + const refreshDmHeader = (): void => { + const dmChannel = dmStore.getState().channels.find((c) => c.channelId === channelId); + // A group has no single presence to show, so the subtitle lists who is + // in it instead — that is the fact a group header is asked for, and a + // first member's status presented as the group's would be a lie. + let subtitle = "Offline"; + if (dmChannel !== undefined && dmChannel.isGroup) { + const names = dmChannel.participants.map((p) => (p.displayName ?? "") || p.username); + subtitle = `${names.length + 1} members: You, ${names.join(", ")}`; + } else if (dmChannel !== undefined) { + const member = membersStore.getState().members.get(dmChannel.recipient.id); + const status = member?.status ?? dmChannel.recipient.status ?? "Offline"; + subtitle = status.charAt(0).toUpperCase() + status.slice(1); + } + const headerName = dmChannel !== undefined ? dmDisplayName(dmChannel) : channelName; + updateChatHeaderForDm(chatHeaderRefs, { username: headerName, status: subtitle }); + }; + refreshDmHeader(); + // Keep the subtitle live across presence and roster changes — otherwise + // it is set once from a snapshot and never updated until the channel is + // re-mounted, same as the topic subscription below does for text + // channels. destroyChannel already tears these down. + if (dmRecipientId !== null) { + composerGatingUnsubs.push( + membersStore.subscribeSelector( + (s) => s.members.get(dmRecipientId)?.status, + refreshDmHeader, + ), + ); } - const headerName = dmChannel !== undefined ? dmDisplayName(dmChannel) : channelName; - updateChatHeaderForDm(chatHeaderRefs, { username: headerName, status: subtitle }); + composerGatingUnsubs.push( + dmStore.subscribeSelector( + (s) => s.channels.find((c) => c.channelId === channelId), + refreshDmHeader, + ), + ); } else if (chatHeaderRefs !== null) { updateChatHeaderForDm(chatHeaderRefs, null); if (chatHeaderName !== null) { diff --git a/Client/tauri-client/src/pages/main-page/MessageJump.ts b/Client/tauri-client/src/pages/main-page/MessageJump.ts index 7d3491ac..59d16ab3 100644 --- a/Client/tauri-client/src/pages/main-page/MessageJump.ts +++ b/Client/tauri-client/src/pages/main-page/MessageJump.ts @@ -52,6 +52,13 @@ function defaultNextFrame(): Promise<void> { export function createMessageJumper(opts: MessageJumpOptions): MessageJumper { const nextFrame = opts.nextFrame ?? defaultNextFrame; + // Generation counter: every jumpTo() call claims the latest generation at + // entry. Concurrent jumps to the same channel are not serialized, so + // without this an older request whose response lands after a newer jump + // already applied its window would silently overwrite it (last network + // reply wins instead of last click). + let jumpGen = 0; + /** Scroll the mounted list to a message, if that list is showing `channelId`. */ function scrollIfMounted(channelId: number, messageId: number): boolean { const ctrl = opts.getChannelCtrl(); @@ -61,6 +68,8 @@ export function createMessageJumper(opts: MessageJumpOptions): MessageJumper { } async function jumpTo(channelId: number, messageId: number): Promise<boolean> { + const gen = ++jumpGen; + // A permalink to a channel this user cannot see must degrade quietly // rather than blank the chat area on an unknown id. if (findChannelById(channelId) === null) { @@ -86,6 +95,10 @@ export function createMessageJumper(opts: MessageJumpOptions): MessageJumper { const resp = await opts.api.getMessagesAround(channelId, messageId, { limit: AROUND_WINDOW, }); + // A newer jump was fired (and possibly already resolved) while this + // fetch was in flight — its response landing now must not clobber the + // window the newer jump already applied. + if (gen !== jumpGen) return false; setAroundMessages(channelId, resp.messages, resp.has_more_before, resp.has_more_after); } catch (err) { if (err instanceof ApiClientError && err.status === 404) { @@ -106,6 +119,7 @@ export function createMessageJumper(opts: MessageJumpOptions): MessageJumper { // The store update re-renders the list; scroll on the next frame. await nextFrame(); + if (gen !== jumpGen) return false; if (scrollIfMounted(channelId, messageId)) return true; log.warn("Around-window loaded but the row did not render", { channelId, messageId }); diff --git a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts index 03f83868..01213dfa 100644 --- a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts +++ b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts @@ -39,6 +39,18 @@ export function mapInviteResponse(r: InviteResponse): InviteItem { }; } +/** + * Whether the server marked this invite revoked. `InviteResponse` does not + * declare the field (redemption enforces it server-side; the list endpoint + * deliberately still includes revoked invites), so this reaches into the raw + * payload the same way `mapInviteResponse` already does for `created_by`. + * Without this, a revoked invite renders identically to a live one — Copy and + * Revoke on a code that redemption always rejects. + */ +function isInviteRevoked(r: InviteResponse): boolean { + return (r as unknown as Record<string, unknown>)["revoked"] === true; +} + // --------------------------------------------------------------------------- // Pinned message mapping // --------------------------------------------------------------------------- @@ -136,6 +148,12 @@ export function createInviteManagerController(opts: { readonly getRoot: () => HTMLDivElement | null; }): InviteManagerController { let instance: MountableComponent | null = null; + // Set for the duration of the getInvites() round trip. `instance` is only + // assigned after the await, so the synchronous `instance !== null` guard + // alone lets a double-click during the fetch mount two overlays — the + // second assignment orphans the first, which is then unreachable by its own + // close affordances. This flag closes that window. + let opening = false; function close(): void { if (instance !== null) { @@ -146,10 +164,11 @@ export function createInviteManagerController(opts: { async function open(): Promise<void> { const root = opts.getRoot(); - if (instance !== null || root === null) return; + if (instance !== null || root === null || opening) return; + opening = true; try { const raw = await opts.api.getInvites(); - const invites = raw.map(mapInviteResponse); + const invites = raw.filter((r) => !isInviteRevoked(r)).map(mapInviteResponse); instance = createInviteManager({ invites, onCreateInvite: async () => { @@ -184,6 +203,8 @@ export function createInviteManagerController(opts: { } catch (err) { log.error("Failed to open invite manager", { error: String(err) }); showToast("Failed to load invites", "error"); + } finally { + opening = false; } } @@ -212,6 +233,10 @@ export function createPinnedPanelController(opts: { readonly onJumpToMessage?: (messageId: number) => void; }): PinnedPanelController { let instance: MountableComponent | null = null; + // Same guard as InviteManagerController.open: `instance` is only assigned + // after the getPins() await, so a double-click during the fetch would + // otherwise mount two panels and orphan the first one permanently. + let opening = false; function close(): void { if (instance !== null) { @@ -225,9 +250,11 @@ export function createPinnedPanelController(opts: { close(); return; } + if (opening) return; const root = opts.getRoot(); const channelId = opts.getCurrentChannelId(); if (root === null || channelId === null) return; + opening = true; try { const resp = await opts.api.getPins(channelId); const pins = resp.messages.map(mapToPinnedMessage); @@ -257,6 +284,8 @@ export function createPinnedPanelController(opts: { } catch (err) { log.error("Failed to load pinned messages", { error: String(err) }); showToast("Failed to load pinned messages", "error"); + } finally { + opening = false; } } diff --git a/Client/tauri-client/src/pages/main-page/ReactionController.ts b/Client/tauri-client/src/pages/main-page/ReactionController.ts index c174eed2..07228267 100644 --- a/Client/tauri-client/src/pages/main-page/ReactionController.ts +++ b/Client/tauri-client/src/pages/main-page/ReactionController.ts @@ -5,7 +5,7 @@ import { createElement } from "@lib/dom"; import { createEmojiPicker } from "@components/EmojiPicker"; -import { getChannelMessages } from "@stores/messages.store"; +import { addOptimisticReaction, getChannelMessages } from "@stores/messages.store"; import type { WsClient } from "@lib/ws"; // --------------------------------------------------------------------------- @@ -38,11 +38,17 @@ export function createReactionController(opts: ReactionControllerOptions): React showError("Slow down! Please wait before reacting again."); return; } - const msgs = getChannelMessages(getChannelId()); + const channelId = getChannelId(); + const msgs = getChannelMessages(channelId); const msg = msgs.find((m) => m.id === msgId); const existing = msg?.reactions.find((r) => r.emoji === emoji); - const type = existing?.me ? "reaction_remove" : "reaction_add"; - ws.send({ type, payload: { message_id: msgId, emoji } }); + const action = existing?.me ? ("remove" as const) : ("add" as const); + const type = action === "remove" ? "reaction_remove" : "reaction_add"; + const correlationId = ws.send({ type, payload: { message_id: msgId, emoji } }); + // Optimistic toggle (ux/messaging §5): the pill flips on the click; the + // server echo is consumed rather than re-applied, and an error reply (or + // transport failure) rolls back exactly this toggle via its envelope id. + addOptimisticReaction(correlationId, { channelId, messageId: msgId, emoji, action }); } let activePickerDestroy: (() => void) | null = null; diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts index b7840441..db1db5cf 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarArea.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -42,7 +42,7 @@ import { uiStore, setSidebarMode, loadCollapsedCategories } from "@stores/ui.sto import { authStore, clearAuth } from "@stores/auth.store"; import { membersStore, getOnlineMembers } from "@stores/members.store"; import { channelsStore, setActiveChannel } from "@stores/channels.store"; -import { dmStore, removeDmChannel } from "@stores/dm.store"; +import { dmStore, closeDmLocally } from "@stores/dm.store"; import { createProfileManager, createTauriBackend } from "@lib/profiles"; import { openAdminPanel } from "@lib/admin-panel"; import { canViewAuditLog } from "@lib/permissions"; @@ -336,9 +336,19 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { modal.mount(document.body); }, onReorderChannel: (reorders) => { - for (const r of reorders) { - void api.adminUpdateChannel(r.channelId, { position: r.newPosition }); - } + // The store already applied the optimistic order (drag-reorder.ts, + // on mouseup). Aggregate the per-channel PATCHes and surface a single + // failure toast — same try/catch+toast contract as onSave/onDelete + // above — instead of a bare `void` per call, which left a rejected or + // failed write unreported and the sidebar showing an order the + // server never accepted. + void Promise.allSettled( + reorders.map((r) => api.adminUpdateChannel(r.channelId, { position: r.newPosition })), + ).then((results) => { + if (results.some((r) => r.status === "rejected")) { + getToast()?.show("Failed to save channel order", "error"); + } + }); }, onPurgeChannel: async (channel, count) => { try { @@ -442,12 +452,10 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { * (the next `ready` restores the truth either way). */ function closeOrLeaveDm(channelId: number): void { - const wasActive = channelsStore.getState().activeChannelId === channelId; - removeDmChannel(channelId); + closeDmLocally(channelId, fallBackFromDm); void api.closeDm(channelId).catch(() => { getToast()?.show("Could not leave that conversation", "error"); }); - if (wasActive) fallBackFromDm(); } /** Rename a group DM (participants only; the server refuses a 1:1). */ diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index 4a56389f..f32936ac 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -53,8 +53,16 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid let localTileAdded = false; let localScreenshareTileAdded = false; let focusedTileId: number | null = null; + /** Set when the user explicitly dismisses the grid while local video is + * still on (switching to a text channel). Without this, checkVideoMode() + * re-opens the grid the moment any remote peer's camera/screenshare + * toggles, since that re-invokes checkVideoMode() and localVideoOn is + * still true. Cleared once local video actually goes off, or when the + * grid is opened again through any other path. */ + let userDismissedVideo = false; function showVideoGrid(): void { + userDismissedVideo = false; if (videoMode) return; videoMode = true; slots.messagesSlot.style.display = "none"; @@ -63,7 +71,10 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid slots.videoGridSlot.style.display = "block"; } - function showChat(): void { + /** Close the grid without recording a dismissal. Used by the paths that + * close it on the user's behalf (no streams left, left the channel, + * teardown) — only an explicit showChat() is a dismissal. */ + function closeVideoGrid(): void { if (!videoMode) return; videoMode = false; focusedTileId = null; @@ -75,16 +86,31 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid slots.videoGridSlot.style.display = "none"; } + function showChat(): void { + // The user asked for chat (switching to a text channel) while still + // broadcasting — remember it, or checkVideoMode() drags them back into + // the grid the next time any peer toggles a camera (v048). + const voice = voiceStore.getState(); + if (voice.localCamera || voice.localScreenshare) { + userDismissedVideo = true; + } + closeVideoGrid(); + } + function checkVideoMode(): void { const voice = voiceStore.getState(); const channelId = voice.currentChannelId; if (channelId === null) { - if (videoMode) showChat(); + // Not a dismissal: leaving voice can clear currentChannelId before + // localCamera/localScreenshare go false, and this early return skips + // the reset below — showChat() here would strand userDismissedVideo + // set and suppress auto-open for the next session. + closeVideoGrid(); return; } const channelUsers = voice.voiceUsers.get(channelId); if (!channelUsers) { - if (videoMode) showChat(); + closeVideoGrid(); return; } @@ -104,13 +130,17 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid anyVideoOn = videoGrid.hasStreams(); } // Auto-close video grid when no streams remain - if (!anyVideoOn && videoMode) { - showChat(); + if (!anyVideoOn) { + closeVideoGrid(); } // BUG-105: Auto-open video grid only for LOCAL camera/screenshare. // Remote streams require manual click (Discord-style behavior). const localVideoOn = voice.localCamera || voice.localScreenshare; - if (localVideoOn && !videoMode) { + if (!localVideoOn) { + // Nothing left to dismiss — the next camera/screenshare start should + // auto-open the grid again. + userDismissedVideo = false; + } else if (!videoMode && !userDismissedVideo) { showVideoGrid(); } @@ -177,10 +207,11 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid } function destroy(): void { - if (videoMode) showChat(); + closeVideoGrid(); focusedTileId = null; localTileAdded = false; localScreenshareTileAdded = false; + userDismissedVideo = false; } return { diff --git a/Client/tauri-client/src/stores/auth.store.ts b/Client/tauri-client/src/stores/auth.store.ts index 441d06b8..0bdf1ba4 100644 --- a/Client/tauri-client/src/stores/auth.store.ts +++ b/Client/tauri-client/src/stores/auth.store.ts @@ -6,7 +6,9 @@ import { createStore } from "@lib/store"; import type { UserWithRole } from "@lib/types"; import { resetVoiceStore, voiceStore } from "@stores/voice.store"; +import { resetMessagesStore } from "@stores/messages.store"; import { cleanupNotificationAudio } from "@lib/notifications"; +import { clearNsfwAcknowledgements } from "@lib/nsfw-gate"; import { createLogger } from "@lib/logger"; const log = createLogger("auth.store"); @@ -27,6 +29,14 @@ export interface AuthState { /** Set by clearAuth; cleared again on the next setAuth. Optional so the * many inline AuthState test fixtures need not restate it. */ readonly logoutReason?: LogoutReason | null; + /** + * Snapshot of "was the user in a voice channel", taken by clearAuth before + * it resets voiceStore. clearAuth applies state synchronously but store + * notifications are microtask-deferred, so a subscriber reacting to + * isAuthenticated flipping false always sees voiceStore already reset — + * this is what such a subscriber must gate a voice_leave send on instead. + */ + readonly logoutWasInVoice?: boolean; } const INITIAL_STATE: AuthState = { @@ -54,7 +64,11 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m * session (WebRTC, AudioContext, streams) and clears voice store state — * including camera/screenshare, whose tracks leaveVoice stops and whose * toggles it resets. Safe to call even if no voice session is active — - * leaveVoice is idempotent. */ + * leaveVoice is idempotent. Also clears messagesStore: otherwise a channel + * id that also exists on the next-signed-into server (channel ids are only + * unique per-server) would short-circuit its refetch and render the + * previous session's messages, and same-account relogin would leave a + * permanent hole for messages posted while logged out. */ export function clearAuth(reason: LogoutReason = "user"): void { // livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded // lazily so it stays out of the startup path. Only import it when there is @@ -63,14 +77,27 @@ export function clearAuth(reason: LogoutReason = "user"): void { // When a voice session exists the module is necessarily already loaded, so // this import resolves from the module cache in a microtask. const voice = voiceStore.getState(); + // Snapshot BEFORE resetVoiceStore() below clears it — this is the last + // moment the pre-logout voice state is knowable. + const wasInVoice = voice.currentChannelId !== null; if (voice.currentChannelId !== null && voice.voiceStatus !== "idle") { void import("@lib/livekitSession") .then(({ leaveVoice }) => leaveVoice(false)) .catch((e) => log.warn("Failed to leave voice session during clearAuth", e)); } resetVoiceStore(); + resetMessagesStore(); + // NSFW acknowledgements are per-viewer consent, not per-device: without this + // the next account signed into the same server inherits the previous user's + // acks and the age gate silently never appears for them. Host-scoping the + // keys cannot cover that case — only clearing on logout can. + clearNsfwAcknowledgements(); cleanupNotificationAudio(); - authStore.setState(() => ({ ...INITIAL_STATE, logoutReason: reason })); + authStore.setState(() => ({ + ...INITIAL_STATE, + logoutReason: reason, + logoutWasInVoice: wasInVoice, + })); } /** Shorthand selector for the current token. */ diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts index 6878d1f0..2e7e5620 100644 --- a/Client/tauri-client/src/stores/channels.store.ts +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -109,10 +109,18 @@ export function setChannels(channels: readonly ReadyChannel[]): void { voiceMaxVideo: ch.voice_max_video ?? 0, }); } - channelsStore.setState((prev) => ({ - ...prev, - channels: map, - })); + channelsStore.setState((prev) => { + // The ready payload never includes DM rows (those arrive via dm_channels + // and are synthesized into this store on selection), so carry them across + // the rebuild — destroying them would break call/profile actions for the + // DM the user is currently viewing until they re-click it. + for (const [id, ch] of prev.channels) { + if (ch.type === "dm" && !map.has(id)) { + map.set(id, ch); + } + } + return { ...prev, channels: map }; + }); } /** Bulk set roles from the ready payload. */ @@ -127,9 +135,13 @@ export function getRoleIdByName(name: string): number | undefined { return match?.id; } -/** Add a single channel from a channel_create event. */ +/** Add a single channel from a channel_create event. The server re-sends + * channel_create to still-visible clients on role/override edits, so the add + * must be idempotent: the broadcast carries no per-user data, and a re-add + * must preserve the existing row's per-user fields instead of resetting them. */ export function addChannel(channel: ChannelCreatePayload): void { channelsStore.setState((prev) => { + const existing = prev.channels.get(channel.id); const next = new Map(prev.channels); next.set(channel.id, { id: channel.id, @@ -138,12 +150,16 @@ export function addChannel(channel: ChannelCreatePayload): void { category: channel.category, topic: channel.topic ?? "", position: channel.position, - unreadCount: 0, - mentionCount: 0, - lastMessageId: null, - // Broadcasts carry no per-user data; default permissive. The next ready - // payload delivers the authoritative can_send. Server enforces regardless. - canSend: true, + unreadCount: existing?.unreadCount ?? 0, + mentionCount: existing?.mentionCount ?? 0, + lastMessageId: existing?.lastMessageId ?? null, + // A targeted channel_create from RefreshChannelVisibility carries this + // viewer's own can_send, so a live role/override edit updates the + // composer without waiting for a reconnect. The field is absent on the + // shared-buffer broadcasts (one frame, many recipients) and on older + // servers — keep the existing verdict there, and default permissive for + // a genuinely new channel. The server enforces regardless. + canSend: channel.can_send ?? existing?.canSend ?? true, slowMode: channel.slow_mode ?? 0, nsfw: channel.nsfw ?? false, voiceMaxUsers: channel.voice_max_users ?? 0, diff --git a/Client/tauri-client/src/stores/dm.store.ts b/Client/tauri-client/src/stores/dm.store.ts index 772dbd08..aa7e2633 100644 --- a/Client/tauri-client/src/stores/dm.store.ts +++ b/Client/tauri-client/src/stores/dm.store.ts @@ -4,6 +4,7 @@ */ import { createStore } from "@lib/store"; +import { channelsStore, removeChannel } from "@stores/channels.store"; export interface DmUser { readonly id: number; @@ -96,6 +97,29 @@ export function removeDmChannel(channelId: number): void { })); } +/** + * Local close/removal for a DM that is gone — closed here, or reported gone + * by the server (`dm_channel_close`, possibly from another signed-in + * device). Drops it from dmStore and, if it was the channel being viewed, + * runs `fallback` so the message list/composer don't stay mounted against a + * channel the server no longer recognizes. `fallback` lets each caller pick + * its own landing spot — the sidebar restores the channel visited before the + * DM; a background close just needs somewhere safe. + * + * Also removes the `type: "dm"` mirror row that `addDmToChannelsStore` + * synthesizes into channelsStore on selection — otherwise the mirror (and + * its unread count) survives the close and every future `ready` rebuild + * (`setChannels` deliberately re-carries dm-typed rows), leaving a phantom + * entry that keeps "Mark All as Read" lit with nothing unread on screen and + * would send `mark_read` for a channel the user no longer has open. + */ +export function closeDmLocally(channelId: number, fallback: () => void): void { + const wasActive = channelsStore.getState().activeChannelId === channelId; + removeDmChannel(channelId); + removeChannel(channelId); + if (wasActive) fallback(); +} + /** Update last message info for a DM channel (on new message) and increment unread. * Moves the channel to the top of the list so new messages are always visible. */ export function updateDmLastMessage( @@ -176,6 +200,37 @@ export function dmDisplayName(dm: DmChannel): string { return `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`; } +/** + * Patch a participant's live profile/presence fields (status, username, + * avatar, displayName) across every DM channel they appear in — both as + * `recipient` and inside `participants`. + * + * dmStore's copy of a partner's status/username/avatar is otherwise only + * ever set wholesale by `setDmChannels` (on `ready`) and `addDmChannel` + * (`dm_channel_open` / REST create); presence and profile-change events + * patch membersStore only, which the DM sidebar never reads. Without this, + * a DM partner going offline or renaming would leave the sidebar row + * showing stale status/name for the rest of the session. + */ +export function updateDmParticipant(userId: number, patch: Partial<DmUser>): void { + dmStore.setState((prev) => { + const patchUser = (u: DmUser): DmUser => (u.id === userId ? { ...u, ...patch } : u); + let changed = false; + const channels = prev.channels.map((c) => { + if (c.recipient.id !== userId && c.participants.every((p) => p.id !== userId)) { + return c; + } + changed = true; + return { + ...c, + recipient: patchUser(c.recipient), + participants: c.participants.map(patchUser), + }; + }); + return changed ? { channels } : prev; + }); +} + /** Increment a DM's mention count. Callers also call updateDmLastMessage — a * mention is always an unread too. */ export function incrementDmMention(channelId: number): void { diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts index 4f00de9a..cac8c9db 100644 --- a/Client/tauri-client/src/stores/messages.store.ts +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -60,11 +60,26 @@ export interface Message { readonly mentionsEveryone?: boolean; } +/** A reaction toggle applied optimistically, awaiting its server echo. Keyed + * by the WS envelope id so an error reply (or local transport failure) can + * roll back exactly this toggle — the same correlation scheme pendingSends + * uses for optimistic message rows. */ +export interface PendingReaction { + readonly channelId: number; + readonly messageId: number; + readonly emoji: string; + readonly action: "add" | "remove"; +} + export interface MessagesState { /** Messages per channel: channelId -> ordered array of Message */ readonly messagesByChannel: ReadonlyMap<number, readonly Message[]>; /** Pending send confirmations: correlationId -> channelId */ readonly pendingSends: ReadonlyMap<string, number>; + /** Optimistic reaction toggles awaiting their echo: correlationId -> toggle. + * The store always sets it; optional only so the many inline MessagesState + * test fixtures need not restate it. */ + readonly pendingReactions?: ReadonlyMap<string, PendingReaction>; /** Whether we've loaded initial messages for a channel */ readonly loadedChannels: ReadonlySet<number>; /** Whether more messages exist above for a channel */ @@ -140,6 +155,7 @@ const MAX_MESSAGES_PER_CHANNEL = 500; const INITIAL_STATE: MessagesState = { messagesByChannel: new Map(), pendingSends: new Map(), + pendingReactions: new Map(), loadedChannels: new Set(), hasMore: new Map(), historyLoadState: new Map(), @@ -185,9 +201,16 @@ export function addMessage(payload: ChatMessagePayload): void { } // 2. Defensive: reconcile the oldest pending optimistic row from this author - // (a broadcast that arrived before its chat_send_ok ack). + // (a broadcast that arrived before its chat_send_ok ack). Content must + // match too — our own echo always carries identical content, while a + // same-author message from another session of this account does not, + // and consuming the pending row for it would orphan the real send. const pendingIdx = existing.findIndex( - (m) => m.status === "pending" && m.correlationId !== null && m.user.id === message.user.id, + (m) => + m.status === "pending" && + m.correlationId !== null && + m.user.id === message.user.id && + m.content === message.content, ); if (pendingIdx !== -1) { const replaced = existing.map((m, i) => (i === pendingIdx ? message : m)); @@ -278,20 +301,36 @@ export function markSendFailed(correlationId: string, errorCode: string | null): /** Remove an optimistic row (retry discards the old row; delete-draft dismisses it). */ export function removeOptimistic(correlationId: string): void { messagesStore.setState((prev) => { - const channelId = prev.pendingSends.get(correlationId); const updatedPending = new Map(prev.pendingSends); updatedPending.delete(correlationId); - if (channelId === undefined) { - return { ...prev, pendingSends: updatedPending }; + + // A "failed" row has already been dropped from pendingSends by + // markSendFailed, so pendingSends can't tell us its channel — scan for + // the row itself instead. This is the common case: Retry/Delete only + // render for status==="failed" rows (renderers.ts), so a pendingSends + // hit here would mean removeOptimistic raced ahead of the row ever + // failing. + const channelId = prev.pendingSends.get(correlationId); + if (channelId !== undefined) { + const existing = prev.messagesByChannel.get(channelId); + if (existing === undefined) { + return { ...prev, pendingSends: updatedPending }; + } + const filtered = existing.filter((m) => m.correlationId !== correlationId); + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(channelId, filtered); + return { ...prev, messagesByChannel: updatedMessages, pendingSends: updatedPending }; } - const existing = prev.messagesByChannel.get(channelId); - if (existing === undefined) { - return { ...prev, pendingSends: updatedPending }; + + for (const [cid, list] of prev.messagesByChannel) { + if (!list.some((m) => m.correlationId === correlationId)) continue; + const filtered = list.filter((m) => m.correlationId !== correlationId); + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(cid, filtered); + return { ...prev, messagesByChannel: updatedMessages, pendingSends: updatedPending }; } - const filtered = existing.filter((m) => m.correlationId !== correlationId); - const updatedMessages = new Map(prev.messagesByChannel); - updatedMessages.set(channelId, filtered); - return { ...prev, messagesByChannel: updatedMessages, pendingSends: updatedPending }; + + return { ...prev, pendingSends: updatedPending }; }); } @@ -314,7 +353,14 @@ export function setChannelLoadError(channelId: number): void { } /** Bulk set messages from a REST response. Marks channel as loaded. - * The server returns messages newest-first; we reverse to chronological order. */ + * The server returns messages newest-first; we reverse to chronological order. + * + * Merges rather than clobbers: a live broadcast or an optimistic send can + * land while the fetch is in flight, and the snapshot predates those rows — + * replacing wholesale would silently discard them (and loadedChannels then + * blocks any refetch until a full reload). Rows from the previous array are + * carried over when they are pending/failed, or "sent" but newer than + * anything in the snapshot. */ export function setMessages( channelId: number, messages: readonly MessageResponse[], @@ -326,14 +372,29 @@ export function setMessages( ? converted.slice(converted.length - MAX_MESSAGES_PER_CHANNEL) : converted; messagesStore.setState((prev) => { + const previous = prev.messagesByChannel.get(channelId) ?? []; + const snapshotIds = new Set(trimmed.map((m) => m.id)); + const maxSnapshotId = trimmed.reduce((max, m) => Math.max(max, m.id), 0); + const carried = previous.filter( + (m) => !snapshotIds.has(m.id) && (m.status !== "sent" || m.id > maxSnapshotId), + ); + let merged = carried.length > 0 ? [...trimmed, ...carried] : trimmed; + const mergeTrimmed = merged.length > MAX_MESSAGES_PER_CHANNEL; + if (mergeTrimmed) { + merged = merged.slice(merged.length - MAX_MESSAGES_PER_CHANNEL); + } + const updatedMessages = new Map(prev.messagesByChannel); - updatedMessages.set(channelId, trimmed); + updatedMessages.set(channelId, merged); const updatedLoaded = new Set(prev.loadedChannels); updatedLoaded.add(channelId); const updatedHasMore = new Map(prev.hasMore); - updatedHasMore.set(channelId, hasMore || converted.length > MAX_MESSAGES_PER_CHANNEL); + updatedHasMore.set( + channelId, + hasMore || converted.length > MAX_MESSAGES_PER_CHANNEL || mergeTrimmed, + ); const updatedLoadState = new Map(prev.historyLoadState); updatedLoadState.delete(channelId); @@ -437,22 +498,34 @@ export function prependMessages( messagesStore.setState((prev) => { const existing = prev.messagesByChannel.get(channelId) ?? []; let combined = [...converted, ...existing]; - // Keep newest messages (end of array); drop oldest loaded history when cap exceeded + // Keep the OLDEST rows (start of array) when the cap is exceeded: the + // user is scrolling up, so the fetched page must survive — trimming it + // would make every cap-hit prepend a content-identical no-op that + // refetches the same page forever. The dropped live tail is restored via + // the detached-window machinery ("Jump to Present"), mirroring + // setAroundMessages' window semantics. const wasTrimmed = combined.length > MAX_MESSAGES_PER_CHANNEL; if (wasTrimmed) { - combined = combined.slice(combined.length - MAX_MESSAGES_PER_CHANNEL); + combined = combined.slice(0, MAX_MESSAGES_PER_CHANNEL); } const updatedMessages = new Map(prev.messagesByChannel); updatedMessages.set(channelId, combined); const updatedHasMore = new Map(prev.hasMore); - // If we trimmed older messages, there are definitely more on the server above. - updatedHasMore.set(channelId, hasMore || wasTrimmed); + // Trimming drops rows below the window, never above it, so "more above" + // is exactly what the server said. + updatedHasMore.set(channelId, hasMore); + + const updatedDetached = new Set(prev.detachedChannels); + if (wasTrimmed) { + updatedDetached.add(channelId); + } return { ...prev, messagesByChannel: updatedMessages, hasMore: updatedHasMore, + detachedChannels: updatedDetached, }; }); } @@ -605,47 +678,134 @@ export function clearChannelMessages(channelId: number): void { }); } +/** + * Apply a single reaction count/me delta to a channel's message list, or null + * when the message is not loaded (nothing to update). Shared by the + * server-echo path, the optimistic apply, and its rollback (which applies the + * inverse action) so the three can never disagree about the arithmetic. + */ +function applyReactionDelta( + prev: MessagesState, + { channelId, messageId, emoji, action }: PendingReaction, + isMe: boolean, +): ReadonlyMap<number, readonly Message[]> | null { + const channelMessages = prev.messagesByChannel.get(channelId); + if (!channelMessages) return null; + + const updatedList = channelMessages.map((msg) => { + if (msg.id !== messageId) return msg; + + const existing = msg.reactions; + if (action === "add") { + const found = existing.find((r) => r.emoji === emoji); + if (found !== undefined) { + const updatedReactions = existing.map((r) => + r.emoji === emoji ? { ...r, count: r.count + 1, me: r.me || isMe } : r, + ); + return { ...msg, reactions: updatedReactions }; + } + return { ...msg, reactions: [...existing, { emoji, count: 1, me: isMe }] }; + } + + // action === "remove" + const updatedReactions = existing + .map((r) => (r.emoji === emoji ? { ...r, count: r.count - 1, me: isMe ? false : r.me } : r)) + .filter((r) => r.count > 0); + return { ...msg, reactions: updatedReactions }; + }); + + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(channelId, updatedList); + return updatedMessages; +} + +/** + * Apply the user's own reaction toggle locally before the server confirms it — + * the pill reacts to the click, not to the round-trip (ux/messaging §5) — and + * register it under the send's correlation id. updateReaction consumes the + * matching self-echo (instead of re-applying it), and rollbackReaction + * reverts the toggle when the send errors. + */ +export function addOptimisticReaction(correlationId: string, toggle: PendingReaction): void { + messagesStore.setState((prev) => { + const updatedMessages = applyReactionDelta(prev, toggle, true); + if (updatedMessages === null) return prev; + const updatedPending = new Map(prev.pendingReactions ?? []); + updatedPending.set(correlationId, toggle); + return { ...prev, messagesByChannel: updatedMessages, pendingReactions: updatedPending }; + }); +} + +/** + * Roll back an optimistic reaction toggle whose send failed (server error + * reply or local transport failure) by applying the inverse delta. Returns + * whether the correlation id matched a pending toggle, so the dispatcher's + * error handler knows the failed envelope was a reaction's. + */ +export function rollbackReaction(correlationId: string): boolean { + let found = false; + messagesStore.setState((prev) => { + const toggle = prev.pendingReactions?.get(correlationId); + if (toggle === undefined) return prev; + found = true; + const updatedPending = new Map(prev.pendingReactions); + updatedPending.delete(correlationId); + const inverse: PendingReaction = { + ...toggle, + action: toggle.action === "add" ? "remove" : "add", + }; + const updatedMessages = applyReactionDelta(prev, inverse, true); + if (updatedMessages === null) { + return { ...prev, pendingReactions: updatedPending }; + } + return { ...prev, messagesByChannel: updatedMessages, pendingReactions: updatedPending }; + }); + return found; +} + /** Update reactions on a message from a reaction_update WS event. */ export function updateReaction(payload: ReactionUpdatePayload, currentUserId: number): void { messagesStore.setState((prev) => { - const channelMessages = prev.messagesByChannel.get(payload.channel_id); - if (!channelMessages) return prev; + const isMe = payload.user_id === currentUserId; - const updatedList = channelMessages.map((msg) => { - if (msg.id !== payload.message_id) return msg; - - const isMe = payload.user_id === currentUserId; - const existing = msg.reactions; - - if (payload.action === "add") { - const found = existing.find((r) => r.emoji === payload.emoji); - if (found !== undefined) { - const updatedReactions = existing.map((r) => - r.emoji === payload.emoji ? { ...r, count: r.count + 1, me: r.me || isMe } : r, - ); - return { ...msg, reactions: updatedReactions }; + // The echo of an optimistic toggle: consume it instead of re-applying — + // the delta arithmetic above would double-count otherwise. Matched by + // content, not envelope id (broadcasts carry no request correlation). + if (isMe) { + for (const [cid, t] of prev.pendingReactions ?? []) { + if ( + t.channelId === payload.channel_id && + t.messageId === payload.message_id && + t.emoji === payload.emoji && + t.action === payload.action + ) { + const updatedPending = new Map(prev.pendingReactions); + updatedPending.delete(cid); + return { ...prev, pendingReactions: updatedPending }; } - return { - ...msg, - reactions: [...existing, { emoji: payload.emoji, count: 1, me: isMe }], - }; } + } - // action === "remove" - const updatedReactions = existing - .map((r) => - r.emoji === payload.emoji ? { ...r, count: r.count - 1, me: isMe ? false : r.me } : r, - ) - .filter((r) => r.count > 0); - return { ...msg, reactions: updatedReactions }; - }); - - const updatedMessages = new Map(prev.messagesByChannel); - updatedMessages.set(payload.channel_id, updatedList); + const updatedMessages = applyReactionDelta( + prev, + { + channelId: payload.channel_id, + messageId: payload.message_id, + emoji: payload.emoji, + action: payload.action, + }, + isMe, + ); + if (updatedMessages === null) return prev; return { ...prev, messagesByChannel: updatedMessages }; }); } +/** Reset the entire store to its initial (empty) state — e.g. on logout. */ +export function resetMessagesStore(): void { + messagesStore.setState(() => INITIAL_STATE); +} + // ----------------------------------------------------------------------------- // Selectors // ----------------------------------------------------------------------------- diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 43bfb11b..932a2eb2 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -51,12 +51,16 @@ export interface VoiceConfig { * - "verified": announce signature checked against the peer's pinned key. * - "unverified": peer published no identity key (legacy) — pin-pending. * - "mismatch": the delivered identity key differs from the pinned one - * (possible server MITM); the peer is blocked until re-pin. */ + * (possible server MITM); the peer is blocked until re-pin. + * - "unknown": the local pin store could not be read (keyring error), so + * no trust decision was possible — the announce was rejected + * (fail closed, DC-08). Distinct from "unverified" so a + * storage fault never reads as "never pinned". */ export interface PeerVerification { readonly userId: number; - readonly status: "verified" | "unverified" | "mismatch"; + readonly status: "verified" | "unverified" | "mismatch" | "unknown"; /** Safety number (identity-key fingerprint) for out-of-band verification; - * null for legacy/unverified/mismatch peers. */ + * null for legacy/unverified/mismatch/unknown peers. */ readonly safetyNumber: string | null; } @@ -71,6 +75,14 @@ export interface VoiceState { * the store; optional for the same fixture reason as peerVerifications. */ readonly localServerMuted?: boolean; readonly localServerDeafened?: boolean; + /** True while push-to-talk is bound and the key is NOT currently held — + * i.e. the mic should be gated (silenced) for PTT reasons. This is + * deliberately a separate flag from localMuted: PTT must never write the + * flag that represents the user's own explicit mute (see ptt.ts and + * livekitSession.setMuted), so a hot-mic press can't undo a self-mute and + * a PTT release can't corrupt the mute toggle's state. Always written by + * the store; optional only for the same fixture reason as localServerMuted. */ + readonly pttGated?: boolean; readonly localCamera: boolean; readonly localScreenshare: boolean; /** Epoch ms when the local user joined the current voice channel (for elapsed timer). */ @@ -94,6 +106,7 @@ const INITIAL_STATE: VoiceState = { localDeafened: false, localServerMuted: false, localServerDeafened: false, + pttGated: false, localCamera: false, localScreenshare: false, joinedAt: null, @@ -114,6 +127,7 @@ export function resetVoiceStore(): void { localDeafened: false, localServerMuted: false, localServerDeafened: false, + pttGated: false, localCamera: false, localScreenshare: false, joinedAt: null, @@ -140,20 +154,29 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void { muted: vs.muted, deafened: vs.deafened, speaking: false, - camera: false, - screenshare: false, + // The ready payload carries the authoritative DB flags; blanking them + // on a mid-call resync would hide a peer's live camera/screenshare + // until they toggled it. Absent (older server) still defaults false. + camera: vs.camera ?? false, + screenshare: vs.screenshare ?? false, serverMuted: vs.server_muted ?? false, serverDeafened: vs.server_deafened ?? false, }); } - // Check if current user is in any voice channel + // Check if current user is in any voice channel, and capture their own + // row so the moderator-imposed flags below can be derived from it — a + // full-ready reconnect (mustFullResync / replay-buffer miss) that + // preserves a live voice session must not leave localServerMuted/ + // localServerDeafened stuck at their pre-reconnect values (v049). const currentUserId = authStore.getState().user?.id ?? 0; let autoJoinChannel: number | null = null; + let selfState: ReadyVoiceState | undefined; if (currentUserId !== 0) { for (const vs of states) { if (vs.user_id === currentUserId) { autoJoinChannel = vs.channel_id; + selfState = vs; break; } } @@ -167,6 +190,8 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void { // registered them yet. Stale IDs are cleared by leaveVoiceChannel() // or resetVoiceStore() on logout. currentChannelId: autoJoinChannel ?? prev.currentChannelId, + localServerMuted: selfState?.server_muted ?? false, + localServerDeafened: selfState?.server_deafened ?? false, })); } @@ -300,6 +325,42 @@ export function setLocalDeafened(deafened: boolean): void { })); } +/** Record whether push-to-talk is currently gating (silencing) the mic — + * i.e. the bound key is not held. Written only from ptt.ts. Deliberately + * separate from localMuted so PTT can never write the flag that represents + * the user's own explicit mute (see the VoiceState.pttGated doc comment). */ +export function setPttGated(gated: boolean): void { + voiceStore.setState((prev) => (prev.pttGated === gated ? prev : { ...prev, pttGated: gated })); +} + +/** Whether the Rust-side PTT key poller is actually able to report key state + * on this platform. Module-level rather than store state: it is a process-wide + * platform capability, not per-session voice state, so `resetVoiceStore()` on + * logout must NOT clear it. + * + * It lives here rather than in livekitSession.ts so `ptt.ts` can write it + * during startup without importing that module — livekitSession pulls in the + * ~1.3 MB livekit-client SDK, which is deliberately kept off the startup path. + * + * Defaults to false so an un-wired or unsupported poller never causes a + * join-time mute that nothing can later lift. */ +let pttPollingLive = false; + +/** Report whether the PTT key-polling backend can actually observe key state. + * Callers MUST reflect REAL backend capability (the `ptt_polling_supported` + * Tauri command), not merely whether a key is bound in preferences: on macOS + * `is_key_down` is a stub returning false and on pure-Wayland Linux + * `DeviceState::checked_new()` returns None, so no `ptt-state` event can ever + * arrive to lift a join-time mute. */ +export function setPttPollingLive(live: boolean): void { + pttPollingLive = live; +} + +/** Whether the PTT poller is live (see `setPttPollingLive`). */ +export function isPttPollingLive(): boolean { + return pttPollingLive; +} + /** Toggle local camera state. */ export function setLocalCamera(enabled: boolean): void { voiceStore.setState((prev) => ({ diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 4bb197cb..d9c9f442 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -2559,90 +2559,179 @@ ul.md-list-nested { filter: saturate(0.3); } -/* ── User Profile Popup ── */ -.user-popup { +/* ── User Profile Popup ── + Discord's popout shape: a floating card anchored to the click, a banner strip + across the top, and the avatar straddling the banner/body seam inside a ring + punched out of the card background. + + `position: fixed` on both layers is load-bearing. These rules replaced an + earlier `.up-*` set that no longer matched the component's markup, so the + card rendered unstyled: `static` positioning discarded the computed + left/top and both layers laid out in normal flow at the end of <body>, + pushing the whole app up the page. */ +.upp-overlay { position: fixed; + inset: 0; z-index: 200; +} +.upp-popup { + position: fixed; + display: flex; + flex-direction: column; background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius-md); - width: 300px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); - display: none; + box-shadow: var(--elevation-high); overflow: hidden; + /* A long "about me" scrolls inside the card instead of growing it off the + bottom of the screen. */ + max-height: calc(100vh - 16px); + opacity: 0; + transform: scale(0.95); + transform-origin: top left; + transition: + opacity var(--transition-fast), + transform var(--transition-fast); } -.user-popup.open { - display: block; +.upp-popup.open { + opacity: 1; + transform: scale(1); } -.up-banner { +.upp-banner { height: 60px; + flex-shrink: 0; + background: var(--accent-gradient); } -.up-body { - padding: 36px 16px 16px; +.upp-body { position: relative; + padding: 44px 16px 16px; + overflow-y: auto; } -.up-avatar { - width: 64px; - height: 64px; - border-radius: var(--radius-circle); +/* Positioned against the card, not the body. The body scrolls, and an + `overflow-y: auto` box also clips horizontally, so an avatar overhanging its + top edge would be sliced in half. Offset = banner height minus half the + avatar, so it straddles the seam. */ +.upp-avatar { position: absolute; - top: -32px; + top: 24px; left: 16px; + box-sizing: border-box; + width: 72px; + height: 72px; + border-radius: var(--radius-circle); + /* The ring is the card background rather than a border colour — that is what + makes the avatar read as punched through the banner. */ + border: 6px solid var(--bg-primary); display: flex; align-items: center; justify-content: center; + font-size: var(--font-size-xxl); font-weight: 700; - font-size: 24px; - color: white; + color: #fff; +} +.upp-status-dot { + position: absolute; + right: -2px; + bottom: -2px; + box-sizing: border-box; + width: 20px; + height: 20px; + border-radius: var(--radius-circle); border: 4px solid var(--bg-primary); } -.up-name { - font-size: 18px; +.upp-username { + font-size: var(--font-size-xl); font-weight: 700; - color: white; + line-height: 1.2; + color: var(--header-primary); + overflow-wrap: anywhere; } -.up-role { - font-size: 12px; - margin-top: 2px; -} -.up-section { - margin-top: 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); - padding: 12px; -} -.up-section-title { - font-size: 11px; - font-weight: 700; - color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; -} -.up-section-text { - font-size: 13px; - color: var(--text-normal); -} -.up-roles { - display: flex; - gap: 4px; - flex-wrap: wrap; - margin-top: 8px; -} -.up-role-tag { - display: flex; +.upp-role-badge { + display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; + margin-top: 8px; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--bg-hover); - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-normal); } -.up-role-dot { +.upp-status-line { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: var(--font-size-sm); + color: var(--text-muted); +} +.upp-role-dot, +.upp-status-dot-inline { + flex-shrink: 0; width: 8px; height: 8px; - border-radius: 50%; + border-radius: var(--radius-circle); +} +/* Both sections are always built and left empty when the user has no data for + them, so they have to collapse rather than render as blank panels. */ +.upp-about, +.upp-join-date { + margin-top: 12px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--bg-secondary); +} +.upp-about:empty, +.upp-join-date:empty { + display: none; +} +.upp-section-title { + margin-bottom: 6px; + font-size: var(--font-size-xxs); + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-faint); +} +.upp-about-text, +.upp-join-text { + font-size: var(--font-size-sm); + color: var(--text-normal); + overflow-wrap: anywhere; +} +.upp-divider { + height: 1px; + margin: 12px 0; + background: var(--border); +} +.upp-actions { + display: flex; + gap: 8px; +} +.upp-action-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border: none; + border-radius: var(--radius-sm); + background: var(--accent); + color: #fff; + font-family: inherit; + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} +.upp-action-btn:hover { + background: var(--accent-hover); +} +@media (prefers-reduced-motion: reduce) { + .upp-popup { + transition: none; + } } /* ── Emoji Picker ── */ diff --git a/Client/tauri-client/tests/e2e/E2E-ISSUES.md b/Client/tauri-client/tests/e2e/E2E-ISSUES.md index ac616b43..dbca0703 100644 --- a/Client/tauri-client/tests/e2e/E2E-ISSUES.md +++ b/Client/tauri-client/tests/e2e/E2E-ISSUES.md @@ -1,46 +1,114 @@ -# E2E Test Status — 2026-03-18 +# E2E Test Status — 2026-08-05 -## 209 tests: 209 passed (100%) +**Verified against:** the 2026-08-05 closure-pass HEAD by an actual local run. +**Command:** `CI=1 npx playwright test --config=playwright.config.ts` +(headless Chromium, 1 worker, retries 2, the same knobs CI uses). -All E2E tests now pass. Previous issues from 2026-03-15 have been resolved. +## Current status: 291 web tests, 291 passed (100%) -## Resolved Issues +| Run | Result | Wall time | +| --- | ------ | --------- | +| Full web suite (`playwright.config.ts`) | **291 / 291 passed**, 0 flaky retries observed | 9.2 min | +| `@parity` subset (`--grep "@parity"` — mirrors the **blocking** `client-e2e-parity` CI job) | **15 / 15 passed** | 33 s | -### Fixed — Voice widget selector mismatches (2 tests) +Changes since the `914cdac` run (+15 tests, 3 new spec files — DC-04's last +client journeys plus the DC-13 smoke): -- `voice-widget.spec.ts:30` — Removed `.voice-users-list` assertion. - Voice users render in the sidebar (`VoiceChannel.ts`), not in VoiceWidget. -- `voice-widget.spec.ts:80` — Replaced `[data-testid='voice-user-3']` with - `.voice-user-item .vu-name` text matcher. VoiceChannel doesn't use - per-user data-testid attributes. +- `voice-e2ee-verify.spec.ts` (6): the voice E2EE identity-verification + surface, driven through the production crypto path — verified badge + + first-sight pin, legacy unverified, mismatch block, modal reject/trust, + and the DC-08 fail-closed "could not check" state. Needed two harness + additions: per-test identity-pin config on the mock + (`identityPins`/`identityPinError`) and a WebSocket shim that parks + LiveKit's `room.connect` so the voice session holds in "securing". +- `updater.spec.ts` (4): banner → download progress (percentage + MB + fallback via real `update-progress` events) → automatic relaunch, the + failure state, and both dismissal paths. +- `a11y-smoke.spec.ts` (5): axe-style structural checks for the DC-13 pass + (dialog roles, focus trap/restore, tablist, combobox, live regions). +- The mock now records every IPC invoke in `window.__invokeLog`, so tests + can assert side effects with no DOM footprint (pin writes, relaunch). -### Previously fixed (2026-03-15 → 2026-03-17) +Earlier changes (2026-08-04 remediation): `cert-tofu.spec.ts` added (6 tests +covering the TOFU first-use/mismatch ceremony — DC-04), `server-strip.spec.ts` +renamed `sidebar-header.spec.ts`, and the mock exposes its event-listener +registry (`window.__tauriEventListeners`) so specs can wait for async listener +registration instead of racing it. -| Root Cause | Tests Fixed | -| ---------- | ----------- | -| No channel auto-selected on login | ~35 tests | -| Settings overlay toggle broken | 24 tests | -| Quick Switcher Ctrl+K not wired | 9 tests | -| Voice widget stays hidden | 4 of 6 tests | -| Member list not rendering members | 7 tests | -| `.status-dot` selector mismatch | 1 test | +Flake accounting rule used here: a spec is *flaky* only if it failed and then +passed on retry within a run; a spec failing every attempt is *failing*, named +by file. This run had neither. -## Anti-Flakiness Improvements (2026-03-18) +## Suite inventory -- **Config**: Added `actionTimeout: 10s`, `navigationTimeout: 15s`, - local retry (1), video on first retry, JUnit XML reporter for CI -- **Helpers**: Added `waitForWsReady()`, `navigateToMainPageReady()`, - `emitWsMessageAndWait()` for timing-safe WS event testing -- **Patterns applied**: Web-first assertions, DOM-signal polling - instead of hardcoded delays, text content matchers over missing - data-testid attributes +- **Web (mocked Tauri):** 40 spec files under `tests/e2e/`, 291 tests. Runs + against the Vite dev server (`playwright.config.ts`) or the production + bundle (`playwright.config.prod.ts`). Fifteen tests across + `emoji-voicemod.parity.spec.ts`, `gating-badges.parity.spec.ts` and + `social.parity.spec.ts` carry the `@parity` tag and gate CI. +- **Native (real Tauri binary via CDP):** 11 spec files under + `tests/e2e/native/`, run by `playwright.config.native.ts` on Windows + (WebView2) — all 11 matched by a project since 2026-08-04. + **Deliberately not wired to CI.** +- **Admin panel (real server, no mocks):** 1 spec / 6 tests under + `tests/e2e/admin/`, run by `playwright.config.admin.ts` — a serial + journey (first-run wizard → dashboard → channel CRUD → audit log → + re-login) against a real Go server booted by + `tests/e2e/admin/start-server.sh`. Needs the Go toolchain + (`npm run test:e2e:admin`). -## Remaining Improvement Plan +## CI wiring (`.github/workflows/ci.yml`) -See `docs/brain/02-Tasks/PLAN-E2E-improvement.md` for Phases 2-6: +| Job | Blocking? | What it runs | +| --- | --------- | ------------ | +| `client-e2e` | **Yes** (blocking since 2026-08-05, DC-07) | Full web suite, every PR | +| `client-e2e-parity` | **Yes** | The `@parity` subset | +| `admin-e2e` | No (`continue-on-error`, earning its soak) | The admin-panel journey vs a real server | +| native config | not in CI | Windows-only; run manually | -- Phase 2: Add `data-testid` to 12 components -- Phase 3: Page Object helpers + dedup -- Phase 4: Strengthen assertions -- Phase 5: Toast coverage -- Phase 6: Migrate to `data-testid` selectors +## Known issues (open) + +1. **`admin-e2e` is non-blocking while it earns its soak** — the same + graduation convention `client-e2e` followed before its 2026-08-05 + promotion (DC-07: green runs at 270/276/291 tests, and the one hard CI + failure in the soak window was a real spec bug, not flake). + +## Resolved (2026-08-04 remediation) + +- ~~Three native specs are never executed.~~ `dm-system.spec.ts`, + `reconnection.spec.ts` and `theme-persistence.spec.ts` (14 tests) joined + `native-authenticated`'s `testMatch` — all three use the persistent + fixture + `ensureLoggedIn`, so the shared-exe project is where they belong. + +## History (dispositions of the old contents of this file) + +The previous revision of this file was dated 2026-03-18 and reported +"209/209 passed". Everything it tracked is long resolved and the counts are +historical only: + +- The 2026-03 selector fixes, auto-select/settings-toggle/quick-switcher/ + member-list repairs: shipped, superseded by four months of suite growth + (209 → 270 tests). +- The anti-flakiness config (`actionTimeout` 10 s, `navigationTimeout` 15 s, + retries, video-on-retry) and the timing-safe helpers (`waitForWsReady()`, + `navigateToMainPageReady()`, `emitWsMessageAndWait()`): still present in + `playwright.config.ts` / `tests/e2e/helpers.ts`. +- The "Remaining Improvement Plan" pointed at + `docs/brain/02-Tasks/PLAN-E2E-improvement.md`, which does not exist in this + repository — reference dropped. Its themes (more `data-testid` selectors, + page-object helpers, toast coverage) survive as ordinary test hygiene, and + toast coverage has since landed (`toast.spec.ts`, + `tests/unit/toast-coverage.test.ts`). +- The 229/255 breakage found by the 2026-07-25 coverage audit + (T-2026-07-25-21) postdated the old file and never appeared in it; it is + recorded and now closed in `docs/audit-test-coverage-2026-07-25.md`. + +## Environment notes for local runs + +- The dev-server config auto-starts Vite on port 1420; make sure it is free. +- `CI=1` gives the bounded CI profile (1 worker, `maxFailures: 20`, global + timeout 20 min) — useful to keep a broken run from burning time. +- The native suite needs a Windows machine with the built Tauri exe and + drives it over CDP; see `playwright.config.native.ts` for the two-project + layout (fresh exe per test for auth flows, one shared exe + login for the + rest, designed around the server's 5-logins/minute rate limit). diff --git a/Client/tauri-client/tests/e2e/a11y-smoke.spec.ts b/Client/tauri-client/tests/e2e/a11y-smoke.spec.ts new file mode 100644 index 00000000..64cd6575 --- /dev/null +++ b/Client/tauri-client/tests/e2e/a11y-smoke.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriConnect, + mockTauriFullSessionWithMessages, + navigateToMainPageReady, + openSettings, +} from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: accessibility smoke over the modal/overlay stack (DC-13) +// +// Axe-style structural checks in the real app — dialog roles, focus +// containment and restore, live regions — asserting the contract the DC-13 +// pass established (lib/a11y.ts + modalFactory + the hand-rolled overlays). +// Unit tests cover each component's semantics exhaustively; this smoke proves +// the wiring holds end-to-end in the running app. +// --------------------------------------------------------------------------- + +test.describe("A11y smoke — dialogs, focus, live regions", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPageReady(page); + }); + + test("settings overlay is a labelled dialog with a tablist, and Escape restores focus to the opener", async ({ + page, + }) => { + await openSettings(page); + + const panel = page.locator(".settings-panel"); + await expect(panel).toHaveAttribute("role", "dialog"); + await expect(panel).toHaveAttribute("aria-modal", "true"); + await expect(page.locator(".settings-sidebar")).toHaveAttribute("role", "tablist"); + // Roving tabindex: exactly one tab is tabbable. + await expect(page.locator('.settings-nav-item[tabindex="0"]')).toHaveCount(1); + // The content pane is the labelled tabpanel of the active tab. + const activeTabId = await page + .locator('.settings-nav-item[aria-selected="true"]') + .getAttribute("id"); + await expect(page.locator(".settings-content")).toHaveAttribute( + "aria-labelledby", + activeTabId ?? "", + ); + + await page.keyboard.press("Escape"); + await expect(panel).not.toBeVisible(); + // Focus returned to the gear button that opened the overlay. + const active = await page.evaluate(() => document.activeElement?.getAttribute("aria-label")); + expect(active).toBe("Settings"); + }); + + test("quick switcher is a dialog wired as a combobox over a listbox", async ({ page }) => { + await page.keyboard.press("Control+k"); + + const switcher = page.locator(".quick-switcher"); + await expect(switcher).toHaveAttribute("role", "dialog"); + await expect(switcher).toHaveAttribute("aria-modal", "true"); + + const input = page.locator(".quick-switcher__input"); + await expect(input).toHaveAttribute("role", "combobox"); + await expect(input).toHaveAttribute("aria-controls", "quick-switcher-results"); + await expect(page.locator("#quick-switcher-results")).toHaveAttribute("role", "listbox"); + + // The active option is exposed to the input via aria-activedescendant. + await input.fill("gen"); + const activeOption = page.locator('.quick-switcher__item[aria-selected="true"]').first(); + await expect(activeOption).toBeVisible(); + const optionId = await activeOption.getAttribute("id"); + await expect(input).toHaveAttribute("aria-activedescendant", optionId ?? ""); + + await page.keyboard.press("Escape"); + await expect(page.locator(".quick-switcher-overlay")).toHaveCount(0); + }); + + test("a factory modal (member picker) traps Tab inside the dialog", async ({ page }) => { + // Open the DM member picker via the "+" on the embedded DM section. + const newDmBtn = page.locator(".sidebar-dm-section .category-add-btn"); + await expect(newDmBtn).toBeVisible({ timeout: 5_000 }); + await newDmBtn.click(); + + const modal = page.locator(".dm-member-picker-modal"); + await expect(modal).toBeVisible({ timeout: 5_000 }); + await expect(modal).toHaveAttribute("role", "dialog"); + await expect(modal).toHaveAttribute("aria-modal", "true"); + + // Focus stays inside the dialog across many Tab presses. + for (let i = 0; i < 12; i++) { + await page.keyboard.press("Tab"); + } + const focusInside = await page.evaluate(() => { + const modalEl = document.querySelector(".dm-member-picker-modal"); + return modalEl !== null && modalEl.contains(document.activeElement); + }); + expect(focusInside).toBe(true); + + await page.keyboard.press("Escape"); + await expect(modal).toHaveCount(0); + }); + + test("toast and typing surfaces are polite live regions", async ({ page }) => { + await expect(page.locator(".toast-container")).toHaveAttribute("aria-live", "polite"); + await expect(page.locator(".toast-container")).toHaveAttribute("role", "status"); + await expect(page.locator(".typing-bar")).toHaveAttribute("aria-live", "polite"); + }); +}); + +test.describe("A11y smoke — cert trust ceremony", () => { + test("the first-use trust modal is a labelled dialog and takes focus", async ({ page }) => { + await mockTauriConnect(page); + await page.goto("/"); + await expect(page.locator(".connect-page")).toBeVisible(); + + await page.waitForFunction( + () => + ((window as unknown as { __tauriEventListeners: Record<string, unknown[]> }) + .__tauriEventListeners["cert-tofu"]?.length ?? 0) > 0, + ); + await page.evaluate(() => { + (window as unknown as { __tauriEmitEvent: (e: string, d: unknown) => void }).__tauriEmitEvent( + "cert-tofu", + { host: "myserver.example:8443", fingerprint: "AA:BB:CC:DD", status: "first_use" }, + ); + }); + + const modal = page.locator(".modal"); + await expect(modal).toBeVisible(); + await expect(modal).toHaveAttribute("role", "dialog"); + await expect(modal).toHaveAttribute("aria-modal", "true"); + await expect(modal).toHaveAttribute("aria-labelledby", "cert-first-use-title"); + await expect(page.locator("#cert-first-use-title")).toHaveText("New Server Certificate"); + + // The dialog took focus on open (its first focusable control). + const focusInside = await page.evaluate(() => { + const modalEl = document.querySelector(".modal"); + return modalEl !== null && modalEl.contains(document.activeElement); + }); + expect(focusInside).toBe(true); + + // Escape is the safe reject: the modal closes, nothing is trusted. + await page.keyboard.press("Escape"); + await expect(modal).toHaveCount(0); + const accepted = await page.evaluate(() => + (window as unknown as { __invokeLog: Array<{ cmd: string }> }).__invokeLog.some( + (e) => e.cmd === "accept_cert_fingerprint", + ), + ); + expect(accepted).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/e2e/admin/admin-panel.spec.ts b/Client/tauri-client/tests/e2e/admin/admin-panel.spec.ts new file mode 100644 index 00000000..c25ec26d --- /dev/null +++ b/Client/tauri-client/tests/e2e/admin/admin-panel.spec.ts @@ -0,0 +1,142 @@ +import { test, expect, type Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Tests: admin panel journey (DC-04's last uncovered surface) +// +// Drives the server-embedded admin SPA (Server/admin/static/index.html) +// against a REAL server — chi router, admin gates, SQLite — started fresh by +// start-server.sh via this config's webServer hook. The tests form ONE +// stateful journey (serial, single worker): the first-run wizard creates the +// owner every later step authenticates as, which is exactly how a real +// deployment's first session goes. +// +// Spec: docs/architecture/ux/settings-and-admin.md §4 (what the admin surface +// owns) and docs/deployment.md First start. +// --------------------------------------------------------------------------- + +const OWNER = { username: "e2e-owner", password: "e2e-owner-pass-123" }; + +test.describe.configure({ mode: "serial" }); + +async function navigate(page: Page, label: string): Promise<void> { + await page.locator(".nav-item", { hasText: label }).click(); +} + +test.describe("Admin panel journey", () => { + // One page for the whole journey: the SPA keeps its session in + // localStorage, and fresh per-test contexts would drop it — forcing a + // login per test straight into the server's 5-logins/min limiter (the + // same reason the native suite uses its persistent fixture). + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test("first-run wizard creates the owner and lands on the dashboard", async () => { + await page.goto("/admin/"); + + // Fresh database → the setup wizard. On a Playwright RETRY the owner + // already exists (setup is one-shot server-side), so the login overlay + // shows instead — sign in and keep the rest of the journey alive rather + // than failing on an assertion the server can no longer satisfy. + const setup = page.locator("#setupOverlay.visible"); + const login = page.locator("#loginOverlay.visible"); + await expect(setup.or(login).first()).toBeVisible({ timeout: 10_000 }); + + if (await login.count()) { + await page.locator("#loginUser").fill(OWNER.username); + await page.locator("#loginPass").fill(OWNER.password); + await page.locator("#loginBtn").click(); + } else { + await page.locator("#wizardBox .btn-accent", { hasText: "Get Started" }).click(); + + // Account step. + await page.locator("#wizUser").fill(OWNER.username); + await page.locator("#wizPass").fill(OWNER.password); + await page.locator("#wizConfirm").fill(OWNER.password); + + // Advance through server/uploads/registration/review without touching + // the prefilled values — they mirror the running config, so nothing + // changes and the server does not restart (restart_required false). + // Steps: 1 account → 2 server → 3 uploads/voice → 4 registration → 5 + // review; the fifth click is Finish, which disables the button into a + // spinner while POST /admin/api/setup runs. + const next = page.locator("#wizNextBtn"); + for (let i = 0; i < 4; i++) { + await next.click(); + } + await next.click(); // Finish + + await expect(page.locator("#setupSuccessOverlay")).toBeVisible({ timeout: 15_000 }); + // The invite code is the server's proof the owner + seed data exist. + await expect(page.locator("#inviteCode")).not.toHaveText(""); + await page.locator("#setupContinueBtn").click(); + } + + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(".page-title", { hasText: "Dashboard" })).toBeVisible(); + }); + + test("dashboard renders live stats — exactly one registered user", async () => { + await page.goto("/admin/"); + // Token persisted in localStorage by the wizard → straight into the app. + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + + const usersCard = page.locator(".stat-card", { hasText: "Total Users" }); + await expect(usersCard.locator(".stat-card-value")).toHaveText("1"); + }); + + test("channel create shows up in the channel table", async () => { + await page.goto("/admin/"); + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + + await navigate(page, "Channels"); + await page.locator("button", { hasText: "Create Channel" }).click(); + await page.locator("#chName").fill("e2e-lounge"); + await page.locator(".modal-footer .btn-accent", { hasText: "Create" }).click(); + + await expect(page.locator(".tbl tbody tr", { hasText: "e2e-lounge" })).toBeVisible(); + }); + + test("channel edit renames it in place", async () => { + await page.goto("/admin/"); + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + await navigate(page, "Channels"); + + const row = page.locator(".tbl tbody tr", { hasText: "e2e-lounge" }).first(); + await row.locator(".act-btn[title='Edit']").click(); + await page.locator("#chEditName").fill("e2e-lounge-renamed"); + await page.locator(".modal-footer .btn-accent", { hasText: "Save" }).click(); + + await expect(page.locator(".tbl tbody tr", { hasText: "e2e-lounge-renamed" })).toBeVisible(); + }); + + test("audit log records the channel mutations", async () => { + await page.goto("/admin/"); + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + + await navigate(page, "Audit Log"); + await expect(page.locator(".badge", { hasText: "channel_create" }).first()).toBeVisible(); + await expect(page.locator(".badge", { hasText: "channel_update" }).first()).toBeVisible(); + }); + + test("logout returns to the login overlay; owner can sign back in", async () => { + await page.goto("/admin/"); + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + + await page.locator(".nav-item", { hasText: "Sign Out" }).click(); + await expect(page.locator("#loginOverlay")).toBeVisible(); + + await page.locator("#loginUser").fill(OWNER.username); + await page.locator("#loginPass").fill(OWNER.password); + await page.locator("#loginBtn").click(); + + await expect(page.locator("#adminShell")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(".page-title", { hasText: "Dashboard" })).toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/admin/start-server.sh b/Client/tauri-client/tests/e2e/admin/start-server.sh new file mode 100755 index 00000000..e2de11a9 --- /dev/null +++ b/Client/tauri-client/tests/e2e/admin/start-server.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Builds and runs a real OwnCord server for the admin-panel e2e suite +# (playwright.config.admin.ts). Fresh temp data dir every run, TLS off so +# Playwright talks plain http, loopback only. The server's cwd is the temp +# dir so the first-boot config.yaml template and the wizard's write-back +# land there instead of in the repo. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$(cd "$SCRIPT_DIR/../../../../../Server" && pwd)" +PORT="${OWNCORD_ADMIN_E2E_PORT:-18446}" + +RUN_DIR="$(mktemp -d -t owncord-admin-e2e-XXXXXX)" +mkdir -p "$RUN_DIR/data" +echo "admin-e2e: data dir $RUN_DIR (port $PORT)" >&2 + +BIN="$RUN_DIR/chatserver" +(cd "$SERVER_DIR" && go build -o "$BIN" .) + +cleanup() { + # Playwright kills the process group on teardown; the trap covers manual + # runs. The temp dir is left behind on failure for post-mortems and + # reaped by the OS otherwise. + [[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +cd "$RUN_DIR" +OWNCORD_SERVER_PORT="$PORT" \ +OWNCORD_SERVER_DATA_DIR="$RUN_DIR/data" \ +OWNCORD_DATABASE_PATH="$RUN_DIR/data/chatserver.db" \ +OWNCORD_TLS_MODE=off \ +OWNCORD_VOICE_AUTO_DOWNLOAD_LIVEKIT=false \ +OWNCORD_LOGGING_LEVEL=warn \ +"$BIN" & +SERVER_PID=$! +wait "$SERVER_PID" diff --git a/Client/tauri-client/tests/e2e/cert-tofu.spec.ts b/Client/tauri-client/tests/e2e/cert-tofu.spec.ts new file mode 100644 index 00000000..1d192895 --- /dev/null +++ b/Client/tauri-client/tests/e2e/cert-tofu.spec.ts @@ -0,0 +1,125 @@ +import { test, expect, type Page } from "@playwright/test"; +import { mockTauriConnect, mockTauriFullSession, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: TOFU certificate ceremony (first-use confirmation + mismatch warning) +// +// The Rust proxies emit a `cert-tofu` Tauri event when a server presents an +// unpinned or changed TLS certificate; main.ts turns that into the blocking +// first-use / mismatch modals. This is the client's core security ceremony, +// previously covered only at unit level (DC-04). +// --------------------------------------------------------------------------- + +interface CertTofuPayload { + host: string; + fingerprint: string; + status: "first_use" | "trusted" | "mismatch"; + storedFingerprint?: string; +} + +// The real Tauri event system delivers a deserialized object payload, so this +// bypasses emitWsEvent (which stringifies) and emits the object directly. +// startCertListener registers via an async invoke roundtrip at bootstrap, so +// wait for the listener before emitting instead of racing it. +async function emitCertTofu(page: Page, payload: CertTofuPayload): Promise<void> { + await page.waitForFunction( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => ((window as any).__tauriEventListeners?.["cert-tofu"]?.length ?? 0) > 0, + ); + await page.evaluate((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__tauriEmitEvent("cert-tofu", data); + }, payload); +} + +const FIRST_USE: CertTofuPayload = { + host: "myserver.example:8443", + fingerprint: "AA:BB:CC:DD:EE:FF:00:11", + status: "first_use", +}; + +const MISMATCH: CertTofuPayload = { + host: "myserver.example:8443", + fingerprint: "99:88:77:66:55:44:33:22", + storedFingerprint: "AA:BB:CC:DD:EE:FF:00:11", + status: "mismatch", +}; + +test.describe("Cert TOFU — first use", () => { + test.beforeEach(async ({ page }) => { + await mockTauriConnect(page); + await page.goto("/"); + await expect(page.locator(".connect-page")).toBeVisible(); + }); + + test("first-use event shows the confirmation modal with host and fingerprint", async ({ + page, + }) => { + await emitCertTofu(page, FIRST_USE); + + await expect(page.locator("h3", { hasText: "New Server Certificate" })).toBeVisible(); + await expect( + page.locator(".cert-fingerprint", { hasText: FIRST_USE.fingerprint }), + ).toBeVisible(); + await expect(page.locator(".cert-details")).toContainText(FIRST_USE.host); + }); + + test("trusting the certificate dismisses the modal", async ({ page }) => { + await emitCertTofu(page, FIRST_USE); + await expect(page.locator("h3", { hasText: "New Server Certificate" })).toBeVisible(); + + await page.locator("button", { hasText: "Trust This Certificate" }).click(); + + await expect(page.locator("h3", { hasText: "New Server Certificate" })).toBeHidden(); + // The ceremony never leaves the connect page — trusting only pins the cert. + await expect(page.locator(".connect-page")).toBeVisible(); + }); + + test("cancelling leaves the host untrusted and closes the modal", async ({ page }) => { + await emitCertTofu(page, FIRST_USE); + await expect(page.locator("h3", { hasText: "New Server Certificate" })).toBeVisible(); + + await page.locator(".modal-footer button", { hasText: "Cancel" }).click(); + + await expect(page.locator("h3", { hasText: "New Server Certificate" })).toBeHidden(); + await expect(page.locator(".connect-page")).toBeVisible(); + }); + + test("a second cert event cannot stack a second modal", async ({ page }) => { + await emitCertTofu(page, FIRST_USE); + await emitCertTofu(page, FIRST_USE); + + await expect(page.locator(".modal-overlay")).toHaveCount(1); + }); +}); + +test.describe("Cert TOFU — mismatch", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("mismatch event shows the warning with previous and current fingerprints", async ({ + page, + }) => { + await emitCertTofu(page, MISMATCH); + + await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeVisible(); + const details = page.locator(".cert-details"); + await expect(details).toContainText("Previous"); + await expect(details).toContainText(MISMATCH.storedFingerprint ?? ""); + await expect(details).toContainText("Current"); + await expect(details).toContainText(MISMATCH.fingerprint); + }); + + test("disconnect on mismatch returns to the connect page", async ({ page }) => { + await emitCertTofu(page, MISMATCH); + await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeVisible(); + + await page.locator(".modal-footer button", { hasText: "Disconnect" }).click(); + + await expect(page.locator("h3", { hasText: "Certificate Warning" })).toBeHidden(); + await expect(page.locator(".connect-page")).toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/gating-badges.parity.spec.ts b/Client/tauri-client/tests/e2e/gating-badges.parity.spec.ts index 99e3b7a1..a5d8549e 100644 --- a/Client/tauri-client/tests/e2e/gating-badges.parity.spec.ts +++ b/Client/tauri-client/tests/e2e/gating-badges.parity.spec.ts @@ -189,9 +189,21 @@ test.describe("@parity per-channel mute", () => { // Persistence: the mute lives in localStorage under the settings prefix, // independent of any store/WS round-trip (see @lib/channel-mutes). - const stored = await page.evaluate(() => - localStorage.getItem("owncord:settings:mutedChannels"), - ); + // + // The key is scoped by server host (`mutedChannels:<host>`) because channel + // ids are per-server autoincrement integers and every profile shares one + // webview origin — so read whichever scoped key exists rather than pinning + // the host the test server happens to be on. + const readMutedChannels = () => + page.evaluate(() => { + const prefix = "owncord:settings:mutedChannels"; + const key = Object.keys(localStorage).find( + (k) => k === prefix || k.startsWith(`${prefix}:`), + ); + return key === undefined ? null : localStorage.getItem(key); + }); + + const stored = await readMutedChannels(); expect(JSON.parse(stored ?? "[]")).toContain(1); // Re-opening the menu reflects the flipped state. @@ -204,9 +216,7 @@ test.describe("@parity per-channel mute", () => { await expect(page.locator("[data-testid='channel-1']")).not.toHaveClass(/muted/, { timeout: 5_000, }); - const storedAfter = await page.evaluate(() => - localStorage.getItem("owncord:settings:mutedChannels"), - ); + const storedAfter = await readMutedChannels(); expect(JSON.parse(storedAfter ?? "[]")).not.toContain(1); }); }); diff --git a/Client/tauri-client/tests/e2e/helpers.ts b/Client/tauri-client/tests/e2e/helpers.ts index d9063a40..837f30bd 100644 --- a/Client/tauri-client/tests/e2e/helpers.ts +++ b/Client/tauri-client/tests/e2e/helpers.ts @@ -433,6 +433,12 @@ export function buildTauriMockScript(opts: { voice_states?: unknown[]; dm_channels?: unknown[]; }; + /** Pinned peer identity keys served by get_identity_pin, keyed by userId + * (string). Absent key = null = "never pinned". */ + identityPins?: Record<string, string>; + /** Make get_identity_pin REJECT — models a transient keyring failure, the + * DC-08 fail-closed path. */ + identityPinError?: boolean; }): string { const readyPayload = buildReadyPayload(opts.readyOverrides); @@ -457,6 +463,13 @@ export function buildTauriMockScript(opts: { } } window.__tauriEmitEvent = __tauriEmitEvent; + // Exposed so tests can wait until a listener is registered before + // emitting — registration goes through an async invoke roundtrip, so + // emitting straight after page load races it. + window.__tauriEventListeners = __eventListeners; + // Chronological record of every IPC invoke ({ cmd, args }) — lets tests + // assert side effects with no DOM footprint (pin writes, restart calls). + window.__invokeLog = []; // ----------------------------------------------------------------------- // HTTP mock state @@ -495,6 +508,10 @@ export function buildTauriMockScript(opts: { }, invoke: async (cmd, args) => { + // Every IPC call is recorded so tests can assert side effects that + // have no DOM footprint (e.g. store_identity_pin, plugin:process|restart). + window.__invokeLog.push({ cmd, args }); + // ---- Events ---- if (cmd === "plugin:event|listen") { const eventName = args?.event; @@ -648,9 +665,18 @@ export function buildTauriMockScript(opts: { // ---- E2EE identity (keyring blob + TOFU pins) ---- // null = "no stored key/pin". ensureIdentityKeyPublished on the ready // event is fire-and-forget (void), so a null store is safe and just - // exercises the fresh-key path. + // exercises the fresh-key path. Pins are configurable per test: + // identityPins seeds get_identity_pin per userId, identityPinError + // makes the read REJECT (the DC-08 "store unreadable" path). if (cmd === "save_identity_key" || cmd === "load_identity_key" || cmd === "delete_identity_key") return null; - if (cmd === "store_identity_pin" || cmd === "get_identity_pin") return null; + if (cmd === "get_identity_pin") { + ${ + opts.identityPinError === true + ? `throw new Error("keyring unavailable (mock)");` + : `return ${JSON.stringify(opts.identityPins ?? {})}[String(args?.userId)] ?? null;` + } + } + if (cmd === "store_identity_pin") return null; // ---- Window/webview plugin stubs ---- if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null; diff --git a/Client/tauri-client/tests/e2e/server-strip.spec.ts b/Client/tauri-client/tests/e2e/sidebar-header.spec.ts similarity index 57% rename from Client/tauri-client/tests/e2e/server-strip.spec.ts rename to Client/tauri-client/tests/e2e/sidebar-header.spec.ts index b64fd0ea..0b758682 100644 --- a/Client/tauri-client/tests/e2e/server-strip.spec.ts +++ b/Client/tauri-client/tests/e2e/sidebar-header.spec.ts @@ -2,20 +2,21 @@ import { test, expect } from "@playwright/test"; import { mockTauriFullSession, navigateToMainPage } from "./helpers"; // --------------------------------------------------------------------------- -// Tests: Server Strip → Unified Sidebar Header -// The ServerStrip component was removed in favor of a unified sidebar header -// with a quick-switch overlay. These tests now verify the unified header. +// Tests: Unified Sidebar Header +// The sidebar header carries the server identity, and the invite button is +// the header's primary action. (This spec began life as server-strip.spec.ts; +// the ServerStrip component was deleted in favor of this unified header with +// a quick-switch overlay, and the file now tests the replacement.) // --------------------------------------------------------------------------- -test.describe("Server Strip", () => { +test.describe("Unified Sidebar Header", () => { test.beforeEach(async ({ page }) => { await mockTauriFullSession(page); await page.goto("/"); await navigateToMainPage(page); }); - test("server strip is visible with server icons", async ({ page }) => { - // Unified sidebar header replaces the old server strip + test("header is visible with the server icon", async ({ page }) => { const header = page.locator(".unified-sidebar-header"); await expect(header).toBeVisible(); @@ -23,21 +24,18 @@ test.describe("Server Strip", () => { await expect(icon).toBeVisible(); }); - test("active server icon shows home initial 'O'", async ({ page }) => { - // The unified header shows "OC" in the server icon + test("server icon shows the server initials", async ({ page }) => { const icon = page.locator(".unified-sidebar-header .server-icon-sm"); await expect(icon).toBeVisible(); await expect(icon).toHaveText("OC"); }); - test("server separator exists between icons", async ({ page }) => { - // Unified sidebar has an invite button separating header from content + test("invite button separates header from sidebar content", async ({ page }) => { const inviteBtn = page.locator("[data-testid='invite-btn']"); await expect(inviteBtn).toBeAttached(); }); - test("add server button shows '+' icon", async ({ page }) => { - // The invite button in the unified header serves as the primary action + test("invite button is the header's primary action", async ({ page }) => { const inviteBtn = page.locator("[data-testid='invite-btn']"); await expect(inviteBtn).toBeVisible(); await expect(inviteBtn).toHaveText("Invite"); diff --git a/Client/tauri-client/tests/e2e/totp-flow.spec.ts b/Client/tauri-client/tests/e2e/totp-flow.spec.ts index 24cd867b..539e63af 100644 --- a/Client/tauri-client/tests/e2e/totp-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/totp-flow.spec.ts @@ -34,6 +34,8 @@ async function mockTotpFailure(page: import("@playwright/test").Page): Promise<v body: { error: "INVALID_CODE", message: "Invalid verification code" }, }, ], + // The verify call fails, so no post-auth WS flow follows. + simulateWsFlow: false, }), ); } diff --git a/Client/tauri-client/tests/e2e/updater.spec.ts b/Client/tauri-client/tests/e2e/updater.spec.ts new file mode 100644 index 00000000..5c190f25 --- /dev/null +++ b/Client/tauri-client/tests/e2e/updater.spec.ts @@ -0,0 +1,175 @@ +import { test, expect, type Page } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPageReady } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: updater journey (DC-04, spec: docs/architecture/ux/settings-and-admin.md §5) +// +// UpdateNotifier mounts on MainPage and checks the server 3 s after mount: +// available → banner "Update vX available" [Update Now] [Later]; installing → +// "Downloading update… N% / N.N MB" driven by the Rust `update-progress` +// event; success → relaunch() (there is no restart *prompt* — the spec's +// "applied" state is an automatic relaunch, observed here as the +// plugin:process|restart invoke); failure → "Update failed." + Dismiss. +// +// The update IPC commands are not part of the base mock, so each test layers +// an invoke wrapper over it (init-script order is preserved and the base +// script assigns __TAURI_INTERNALS__ synchronously). +// --------------------------------------------------------------------------- + +async function mockUpdaterSession( + page: Page, + opts: { available: boolean; version?: string; failInstall?: boolean }, +): Promise<void> { + await mockTauriFullSession(page); + await page.addInitScript((cfg) => { + const t = ( + window as unknown as { + __TAURI_INTERNALS__: { invoke: (c: string, a?: unknown) => Promise<unknown> }; + } + ).__TAURI_INTERNALS__; + const orig = t.invoke.bind(t); + const w = window as unknown as { + __resolveInstall: (() => void) | null; + __rejectInstall: ((e: Error) => void) | null; + }; + w.__resolveInstall = null; + w.__rejectInstall = null; + t.invoke = async (cmd: string, args?: unknown) => { + if (cmd === "check_client_update") { + // Short-circuits before the base mock, so this command never appears + // in __invokeLog — assertions below only rely on logged base commands. + return cfg.available + ? { available: true, version: cfg.version, body: "release notes" } + : { available: false, version: null, body: null }; + } + if (cmd === "download_and_install_update") { + // Held open until the test resolves/rejects it, so progress events + // can be asserted deterministically mid-download. + return new Promise<void>((res, rej) => { + w.__resolveInstall = res; + w.__rejectInstall = rej; + }); + } + return orig(cmd, args); + }; + }, opts); +} + +/** The install settle handles exist only once the app's + * download_and_install_update invoke reaches the mock wrapper — wait for + * them instead of racing it. Locally the invoke usually wins that race; + * CI runners demonstrably lose it (the banner flips to "Downloading…" + * synchronously on click, before the invoke's microtask runs). */ +async function waitForInstallHandles(page: Page): Promise<void> { + await page.waitForFunction( + () => + typeof (window as unknown as { __rejectInstall: unknown }).__rejectInstall === "function" && + typeof (window as unknown as { __resolveInstall: unknown }).__resolveInstall === "function", + ); +} + +/** Wait for the update-progress listener, then emit a RAW object payload — + * the Tauri event system delivers deserialized objects, and emitWsEvent's + * stringify would break `event.payload.received`. */ +async function emitProgress(page: Page, received: number, total: number | null): Promise<void> { + await page.waitForFunction( + () => + ((window as unknown as { __tauriEventListeners: Record<string, unknown[]> }) + .__tauriEventListeners["update-progress"]?.length ?? 0) > 0, + ); + await page.evaluate( + (p) => + (window as unknown as { __tauriEmitEvent: (e: string, d: unknown) => void }).__tauriEmitEvent( + "update-progress", + p, + ), + { received, total }, + ); +} + +const banner = (page: Page): ReturnType<Page["locator"]> => page.locator(".update-banner"); +const bannerText = (page: Page): ReturnType<Page["locator"]> => + page.locator(".update-banner .update-banner-text"); + +test.describe("Updater journey", () => { + test("no banner when the server reports no update", async ({ page }) => { + await mockUpdaterSession(page, { available: false }); + await page.goto("/"); + await navigateToMainPageReady(page); + + // The check fires 3 s after mount; give it time to (not) show. + await page.waitForTimeout(4_000); + await expect(banner(page)).toHaveCount(0); + }); + + test("available → banner with version; Later dismisses it for the session", async ({ page }) => { + await mockUpdaterSession(page, { available: true, version: "9.9.9" }); + await page.goto("/"); + await navigateToMainPageReady(page); + + await expect(bannerText(page)).toHaveText("Update v9.9.9 available", { timeout: 10_000 }); + + await page.locator(".update-banner-later").click(); + await expect(banner(page)).toHaveCount(0); + }); + + test("full journey: banner → download progress (% and MB fallback) → auto-relaunch", async ({ + page, + }) => { + await mockUpdaterSession(page, { available: true, version: "9.9.9" }); + await page.goto("/"); + await navigateToMainPageReady(page); + await expect(bannerText(page)).toHaveText("Update v9.9.9 available", { timeout: 10_000 }); + + await page.locator(".update-banner-install").click(); + await expect(bannerText(page)).toHaveText("Downloading update…"); + + // Progress with a known total renders a percentage… + await emitProgress(page, 25, 100); + await expect(bannerText(page)).toHaveText("Downloading update… 25%"); + // …and an unknown total falls back to bytes so the banner never looks hung. + await emitProgress(page, 2 * 1024 * 1024, null); + await expect(bannerText(page)).toHaveText("Downloading update… 2.0 MB"); + + // Install completes → the app relaunches itself (spec: "applied — App + // relaunches automatically"; there is no separate restart prompt). + await waitForInstallHandles(page); + await page.evaluate(() => + (window as unknown as { __resolveInstall: () => void }).__resolveInstall(), + ); + await page.waitForFunction(() => + (window as unknown as { __invokeLog: Array<{ cmd: string }> }).__invokeLog.some( + (e) => e.cmd === "plugin:process|restart", + ), + ); + }); + + test("a failed install shows the error state and Dismiss clears it", async ({ page }) => { + await mockUpdaterSession(page, { available: true, version: "9.9.9" }); + await page.goto("/"); + await navigateToMainPageReady(page); + await expect(bannerText(page)).toHaveText("Update v9.9.9 available", { timeout: 10_000 }); + + await page.locator(".update-banner-install").click(); + await expect(bannerText(page)).toHaveText("Downloading update…"); + + await waitForInstallHandles(page); + await page.evaluate(() => + (window as unknown as { __rejectInstall: (e: Error) => void }).__rejectInstall( + new Error("signature verification failed"), + ), + ); + + await expect(bannerText(page)).toHaveText("Update failed. Please try again later."); + // No relaunch on failure. + const restarted = await page.evaluate(() => + (window as unknown as { __invokeLog: Array<{ cmd: string }> }).__invokeLog.some( + (e) => e.cmd === "plugin:process|restart", + ), + ); + expect(restarted).toBe(false); + + await page.locator(".update-banner-later").click(); + await expect(banner(page)).toHaveCount(0); + }); +}); diff --git a/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts new file mode 100644 index 00000000..fd8228f0 --- /dev/null +++ b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts @@ -0,0 +1,352 @@ +import { test, expect, type Page } from "@playwright/test"; +import { + buildTauriMockScript, + MOCK_LOGIN_RESPONSE, + MOCK_MESSAGES, + MOCK_CHANNELS_WITH_CATEGORIES, + MOCK_MEMBERS_MULTI_ROLE, + MOCK_VOICE_STATE, + navigateToMainPageReady, + joinVoiceChannelByName, + emitWsMessage, +} from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: voice E2EE identity-verification surface (DC-04, spec: +// docs/architecture/ux/voice-and-e2ee.md §7) +// +// Covers the roster badge states (verified / unverified / mismatch / unknown) +// and the identity-mismatch modal journey (review → reject keeps the peer +// blocked; trust re-pins the displayed key). The peer's announce is REAL +// crypto — an ECDSA P-256 identity key signing an ECDH ephemeral key exactly +// as e2eeCrypto.signEphemeralKey does — so the badge states come out of the +// production verification path, not a shortcut. +// +// Harness notes: +// - Verification only runs once the local ECDH keypair exists, which requires +// a voice_token; the stock voiceWsHandlers deliberately withhold it. This +// spec's voice_join handler DOES send one with is_key_holder: true (the key +// holder path returns from setupKeyExchange immediately), and a WebSocket +// shim parks the resulting LiveKit room.connect forever so the session sits +// stably in "securing" instead of self-destructing mid-test. +// - Announces may be emitted before joining: handleAnnounce queues them until +// the keypair exists and setupKeyExchange drains the queue through the +// verifying path, so there is no race either way. +// --------------------------------------------------------------------------- + +const subtle = globalThis.crypto.subtle; + +interface PeerCrypto { + /** The peer's long-term identity public key (raw P-256, base64). */ + identityPublicKeyB64: string; + /** The peer's ephemeral ECDH public key for this call (raw P-256, base64). */ + ephemeralPublicKeyB64: string; + /** ECDSA signature over domain ‖ userId ‖ ephemeralRaw, base64. */ + signatureB64: string; +} + +/** Generate a peer's identity + ephemeral keys and the signed announce, + * mirroring e2eeCrypto.signEphemeralKey's message construction exactly. */ +async function makePeerCrypto(userId: number): Promise<PeerCrypto> { + const identity = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + const ephemeral = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); + const idPubRaw = new Uint8Array(await subtle.exportKey("raw", identity.publicKey)); + const ephPubRaw = new Uint8Array(await subtle.exportKey("raw", ephemeral.publicKey)); + + const domain = new TextEncoder().encode("owncord-voice-e2ee-announce-v1"); + const uid = new TextEncoder().encode(String(userId)); + const message = new Uint8Array(domain.length + uid.length + ephPubRaw.length); + message.set(domain, 0); + message.set(uid, domain.length); + message.set(ephPubRaw, domain.length + uid.length); + const sig = new Uint8Array( + await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.privateKey, message), + ); + + return { + identityPublicKeyB64: Buffer.from(idPubRaw).toString("base64"), + ephemeralPublicKeyB64: Buffer.from(ephPubRaw).toString("base64"), + signatureB64: Buffer.from(sig).toString("base64"), + }; +} + +/** voice_join reply that, unlike the stock voiceWsHandlers, also grants a + * voice_token — required to mint the local ECDH keypair that gates + * verification. is_key_holder: true avoids the non-key-holder's 15s stall. */ +function voiceJoinWithTokenHandler(): { type: string; handler: string } { + return { + type: "voice_join", + handler: ` + var p = parsed.payload; + setTimeout(function() { + __tauriEmitEvent("ws-message", JSON.stringify({ + type: "voice_state", + payload: { user_id: 1, channel_id: p.channel_id, username: "testuser", muted: false, deafened: false, speaking: false, camera: false, screenshare: false } + })); + }, 50); + setTimeout(function() { + __tauriEmitEvent("ws-message", JSON.stringify({ + type: "voice_token", + payload: { token: "mock-token", url: "ws://localhost:7880", channel_id: p.channel_id, direct_url: "", is_key_holder: true } + })); + }, 80); + `, + }; +} + +/** Mock session where remote user 2 ("moderator1") is in the voice channel and + * may carry a published identity key and/or a stored pin. */ +async function mockE2EEVoiceSession( + page: Page, + opts: { + peerIdentityKeyB64?: string; + identityPins?: Record<string, string>; + identityPinError?: boolean; + }, +): Promise<void> { + const members = MOCK_MEMBERS_MULTI_ROLE.map((m) => + m.id === 2 && opts.peerIdentityKeyB64 !== undefined + ? { ...m, identity_public_key: opts.peerIdentityKeyB64 } + : m, + ); + await page.addInitScript( + buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES }, + ], + simulateWsFlow: true, + wsHandlers: [voiceJoinWithTokenHandler()], + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members, + voice_states: MOCK_VOICE_STATE, + }, + identityPins: opts.identityPins, + identityPinError: opts.identityPinError, + }), + ); + // Park the LiveKit signal WebSocket forever: room.connect neither succeeds + // nor fails during the test, so the voice session stays in "securing" and + // never tears down the verification state mid-assertion. + await page.addInitScript(() => { + const RealWS = window.WebSocket; + function ParkedOrReal(url: string | URL, protocols?: string | string[]): WebSocket { + const s = String(url); + if (s.includes("localhost:7880") || s.includes("127.0.0.1:7880")) { + const parked = new EventTarget() as unknown as Record<string, unknown>; + parked.url = s; + parked.readyState = 0; // CONNECTING, forever + parked.binaryType = "arraybuffer"; + parked.send = () => {}; + parked.close = () => { + parked.readyState = 3; + }; + parked.onopen = null; + parked.onmessage = null; + parked.onerror = null; + parked.onclose = null; + return parked as unknown as WebSocket; + } + return new RealWS(url, protocols); + } + ParkedOrReal.prototype = RealWS.prototype; + Object.assign(ParkedOrReal, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + (window as unknown as { WebSocket: unknown }).WebSocket = ParkedOrReal; + }); +} + +async function emitPeerAnnounce( + page: Page, + userId: number, + crypto: PeerCrypto, + withSignature = true, +): Promise<void> { + await emitWsMessage(page, { + type: "voice_e2ee_announce", + payload: { + user_id: userId, + public_key: crypto.ephemeralPublicKeyB64, + ...(withSignature ? { signature: crypto.signatureB64 } : {}), + }, + }); +} + +/** IPC calls of one command recorded by the mock's invoke log. */ +async function invokesOf(page: Page, cmd: string): Promise<Array<Record<string, unknown>>> { + return page.evaluate( + (c) => + ( + window as unknown as { __invokeLog: Array<{ cmd: string; args: Record<string, unknown> }> } + ).__invokeLog + .filter((e) => e.cmd === c) + .map((e) => e.args), + cmd, + ); +} + +const peerBadge = (page: Page): ReturnType<Page["locator"]> => + page.locator('.voice-user-item[data-voice-uid="2"] .vu-verify'); + +test.describe("Voice E2EE identity verification (§7)", () => { + test("a valid signed announce verifies the peer: green badge with safety number, pinned on first sight", async ({ + page, + }) => { + const peer = await makePeerCrypto(2); + await mockE2EEVoiceSession(page, { peerIdentityKeyB64: peer.identityPublicKeyB64 }); + await page.goto("/"); + await navigateToMainPageReady(page); + + await emitPeerAnnounce(page, 2, peer); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toBeVisible({ timeout: 10_000 }); + await expect(badge).toHaveClass(/verified/); + await expect(badge).toHaveAttribute("title", /^Identity verified · Safety number: /); + + // TOFU: the first verified sighting pins the peer's identity key. + const pins = await invokesOf(page, "store_identity_pin"); + expect(pins).toHaveLength(1); + expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 }); + }); + + test("a legacy peer with no published key is accepted but shows the neutral unverified badge", async ({ + page, + }) => { + const peer = await makePeerCrypto(2); + await mockE2EEVoiceSession(page, {}); // no identity_public_key on member 2 + await page.goto("/"); + await navigateToMainPageReady(page); + + await emitPeerAnnounce(page, 2, peer, false); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toBeVisible({ timeout: 10_000 }); + await expect(badge).toHaveClass(/unverified/); + await expect(badge).toHaveAttribute( + "title", + "Identity not verified — this participant published no key", + ); + expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0); + }); + + test("a pinned peer whose delivered key changed is blocked with the red mismatch badge", async ({ + page, + }) => { + const peer = await makePeerCrypto(2); + const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64; // a different, previously-pinned key + await mockE2EEVoiceSession(page, { + peerIdentityKeyB64: peer.identityPublicKeyB64, + identityPins: { "2": oldPin }, + }); + await page.goto("/"); + await navigateToMainPageReady(page); + + await emitPeerAnnounce(page, 2, peer); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toBeVisible({ timeout: 10_000 }); + await expect(badge).toHaveClass(/mismatch/); + await expect(badge).toHaveAttribute( + "title", + "Identity key changed — click to review and re-pin", + ); + // Blocked means blocked: nothing was re-pinned behind the user's back. + expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0); + }); + + test("mismatch modal journey — reject (Cancel) keeps the peer blocked", async ({ page }) => { + const peer = await makePeerCrypto(2); + const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64; + await mockE2EEVoiceSession(page, { + peerIdentityKeyB64: peer.identityPublicKeyB64, + identityPins: { "2": oldPin }, + }); + await page.goto("/"); + await navigateToMainPageReady(page); + await emitPeerAnnounce(page, 2, peer); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toHaveClass(/mismatch/, { timeout: 10_000 }); + await badge.click(); + + // The modal shows the participant and the NEW key's fingerprint so the + // user can verify it out-of-band before trusting. + await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeVisible(); + await expect(page.locator(".cert-title")).toHaveText("Identity Key Changed"); + await expect(page.locator(".cert-details")).toContainText("moderator1"); + await expect(page.locator(".cert-details .cert-fingerprint")).toBeVisible(); + + await page.locator(".modal-footer button", { hasText: "Cancel" }).click(); + + await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden(); + // Reject leaves the peer blocked for E2EE media: badge stays red, no pin write. + await expect(badge).toHaveClass(/mismatch/); + expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0); + }); + + test("mismatch modal journey — Trust New Key re-pins the displayed key and clears the block", async ({ + page, + }) => { + const peer = await makePeerCrypto(2); + const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64; + await mockE2EEVoiceSession(page, { + peerIdentityKeyB64: peer.identityPublicKeyB64, + identityPins: { "2": oldPin }, + }); + await page.goto("/"); + await navigateToMainPageReady(page); + await emitPeerAnnounce(page, 2, peer); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toHaveClass(/mismatch/, { timeout: 10_000 }); + await badge.click(); + await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeVisible(); + + await page.locator(".modal-footer button", { hasText: "Trust New Key" }).click(); + + await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden(); + // The EXACT displayed key was pinned (TOCTOU-safe re-pin), and the + // mismatch block cleared — the badge disappears until the next announce + // re-verifies against the new pin. + await expect(badge).toHaveCount(0); + const pins = await invokesOf(page, "store_identity_pin"); + expect(pins).toHaveLength(1); + expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 }); + }); + + test("an unreadable pin store fails closed: distinct 'could not check' badge, nothing pinned (DC-08)", async ({ + page, + }) => { + const peer = await makePeerCrypto(2); + await mockE2EEVoiceSession(page, { + peerIdentityKeyB64: peer.identityPublicKeyB64, + identityPinError: true, + }); + await page.goto("/"); + await navigateToMainPageReady(page); + + await emitPeerAnnounce(page, 2, peer); + await joinVoiceChannelByName(page); + + const badge = peerBadge(page); + await expect(badge).toBeVisible({ timeout: 10_000 }); + await expect(badge).toHaveClass(/unknown/); + await expect(badge).toHaveAttribute("title", /Could not check/); + // Fail closed: a storage fault must never read as "never pinned" — the + // valid-looking key is NOT verified and NOT pinned. + await expect(badge).not.toHaveClass(/verified/); + expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0); + }); +}); diff --git a/Client/tauri-client/tests/unit/a11y.test.ts b/Client/tauri-client/tests/unit/a11y.test.ts new file mode 100644 index 00000000..09024ed2 --- /dev/null +++ b/Client/tauri-client/tests/unit/a11y.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { applyDialogSemantics, trapFocus, focusDialog } from "@lib/a11y"; + +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + container.remove(); +}); + +function tab(el: Element, shiftKey = false): KeyboardEvent { + const e = new KeyboardEvent("keydown", { key: "Tab", shiftKey, bubbles: true, cancelable: true }); + el.dispatchEvent(e); + return e; +} + +describe("applyDialogSemantics", () => { + it("stamps role, aria-modal, and tabindex", () => { + const el = document.createElement("div"); + applyDialogSemantics(el, { label: "Settings" }); + + expect(el.getAttribute("role")).toBe("dialog"); + expect(el.getAttribute("aria-modal")).toBe("true"); + expect(el.getAttribute("tabindex")).toBe("-1"); + expect(el.getAttribute("aria-label")).toBe("Settings"); + }); + + it("prefers labelledBy over label", () => { + const el = document.createElement("div"); + applyDialogSemantics(el, { label: "x", labelledBy: "title-id" }); + + expect(el.getAttribute("aria-labelledby")).toBe("title-id"); + expect(el.getAttribute("aria-label")).toBeNull(); + }); +}); + +describe("trapFocus", () => { + function buildDialog(): { + dialog: HTMLDivElement; + first: HTMLButtonElement; + last: HTMLButtonElement; + } { + const dialog = document.createElement("div"); + applyDialogSemantics(dialog); + const first = document.createElement("button"); + first.textContent = "first"; + const last = document.createElement("button"); + last.textContent = "last"; + dialog.appendChild(first); + dialog.appendChild(last); + container.appendChild(dialog); + return { dialog, first, last }; + } + + it("wraps Tab from the last focusable to the first", () => { + const ac = new AbortController(); + const { dialog, first, last } = buildDialog(); + trapFocus(dialog, ac.signal); + + last.focus(); + const e = tab(last); + + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(first); + ac.abort(); + }); + + it("wraps Shift+Tab from the first focusable to the last", () => { + const ac = new AbortController(); + const { dialog, first, last } = buildDialog(); + trapFocus(dialog, ac.signal); + + first.focus(); + const e = tab(first, true); + + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(last); + ac.abort(); + }); + + it("lets Tab move freely between interior focusables", () => { + const ac = new AbortController(); + const { dialog, first } = buildDialog(); + const middle = document.createElement("input"); + dialog.insertBefore(middle, dialog.lastChild); + trapFocus(dialog, ac.signal); + + first.focus(); + const e = tab(first); + + // Not at an edge — the browser's normal tab order applies. + expect(e.defaultPrevented).toBe(false); + ac.abort(); + }); + + it("wraps from the container itself (dialog focused, nothing inside focused yet)", () => { + const ac = new AbortController(); + const { dialog, first } = buildDialog(); + trapFocus(dialog, ac.signal); + + dialog.focus(); + tab(dialog); + + expect(document.activeElement).toBe(first); + ac.abort(); + }); + + it("holds focus on a dialog with no focusable content", () => { + const ac = new AbortController(); + const dialog = document.createElement("div"); + applyDialogSemantics(dialog); + container.appendChild(dialog); + trapFocus(dialog, ac.signal); + + dialog.focus(); + const e = tab(dialog); + + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(dialog); + ac.abort(); + }); + + it("stops trapping once the signal aborts", () => { + const ac = new AbortController(); + const { dialog, last } = buildDialog(); + trapFocus(dialog, ac.signal); + ac.abort(); + + last.focus(); + const e = tab(last); + + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("focusDialog", () => { + it("focuses the first focusable control and restores on close", () => { + const outside = document.createElement("button"); + container.appendChild(outside); + outside.focus(); + + const dialog = document.createElement("div"); + applyDialogSemantics(dialog); + const btn = document.createElement("button"); + dialog.appendChild(btn); + container.appendChild(dialog); + + const restore = focusDialog(dialog); + expect(document.activeElement).toBe(btn); + + restore(); + expect(document.activeElement).toBe(outside); + }); + + it("focuses the container when nothing inside is focusable", () => { + const dialog = document.createElement("div"); + applyDialogSemantics(dialog); + container.appendChild(dialog); + + focusDialog(dialog); + + expect(document.activeElement).toBe(dialog); + }); + + it("does not restore to an element no longer in the document", () => { + const outside = document.createElement("button"); + container.appendChild(outside); + outside.focus(); + + const dialog = document.createElement("div"); + applyDialogSemantics(dialog); + container.appendChild(dialog); + const restore = focusDialog(dialog); + + outside.remove(); + expect(() => restore()).not.toThrow(); + expect(document.activeElement).not.toBe(outside); + }); +}); diff --git a/Client/tauri-client/tests/unit/admin-actions.test.ts b/Client/tauri-client/tests/unit/admin-actions.test.ts index b595f2d0..9a2cd77e 100644 --- a/Client/tauri-client/tests/unit/admin-actions.test.ts +++ b/Client/tauri-client/tests/unit/admin-actions.test.ts @@ -62,6 +62,57 @@ describe("AdminActions", () => { result.destroy(); }); + it("a role change in flight ignores further clicks (double-fire guard)", async () => { + let resolveChange: (() => void) | null = null; + const onChangeRole = vi.fn( + () => + new Promise<void>((res) => { + resolveChange = res; + }), + ); + const { result } = makeMenu({ onChangeRole }); + const submenu = result.element.querySelector(".context-menu__submenu")!; + const adminOption = Array.from(submenu.querySelectorAll(".context-menu__item")).find( + (i) => i.textContent === "admin", + ) as HTMLElement; + const modOption = Array.from(submenu.querySelectorAll(".context-menu__item")).find( + (i) => i.textContent === "moderator", + ) as HTMLElement; + + adminOption.click(); + // `currentRole` only updates when member_update echoes, so both a + // double-click and a different option must be inert while in flight. + adminOption.click(); + modOption.click(); + + expect(onChangeRole).toHaveBeenCalledTimes(1); + expect(adminOption.classList.contains("context-menu__item--pending")).toBe(true); + + resolveChange!(); + await vi.waitFor(() => { + expect(adminOption.classList.contains("context-menu__item--pending")).toBe(false); + }); + + // Settled: a new change may fire again. + modOption.click(); + expect(onChangeRole).toHaveBeenCalledTimes(2); + result.destroy(); + }); + + it("clicking the current role is a no-op", () => { + const onChangeRole = vi.fn(async () => {}); + const { result } = makeMenu({ onChangeRole }); + const submenu = result.element.querySelector(".context-menu__submenu")!; + const memberOption = Array.from(submenu.querySelectorAll(".context-menu__item")).find( + (i) => i.textContent === "member", + ) as HTMLElement; + + memberOption.click(); + + expect(onChangeRole).not.toHaveBeenCalled(); + result.destroy(); + }); + it("marks current role as active in submenu", () => { const { result } = makeMenu(); const submenu = result.element.querySelector(".context-menu__submenu"); diff --git a/Client/tauri-client/tests/unit/api.test.ts b/Client/tauri-client/tests/unit/api.test.ts index cb003b40..ac9a9dac 100644 --- a/Client/tauri-client/tests/unit/api.test.ts +++ b/Client/tauri-client/tests/unit/api.test.ts @@ -359,15 +359,34 @@ describe("API Client", () => { expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/password"); expect(fetchCallOpts().method).toBe("PUT"); const body = JSON.parse(fetchCallOpts().body as string); - expect(body).toEqual({ current_password: "oldpw", new_password: "newpw" }); + // The server decodes json:"old_password" (Server/api/profile_handler.go), + // and Go's encoding/json has no alias matching: any other key is a 400. + expect(body).toEqual({ old_password: "oldpw", new_password: "newpw" }); }); it("getSessions calls correct endpoint", async () => { - mockFetch.mockResolvedValue(jsonResponse([])); + mockFetch.mockResolvedValue(jsonResponse({ sessions: [] })); await api.getSessions(); expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me/sessions"); }); + // Regression for v112: the server wraps the list in a {sessions: [...]} + // envelope (Server/api/profile_handler.go's sessionsListResponse); a bare + // array would make every consumer's .map/.length fail or read undefined. + it("getSessions unwraps the {sessions: [...]} envelope", async () => { + const session = { + id: 1, + device: "Chrome on Linux", + ip: "127.0.0.1", + created_at: "2026-01-01T00:00:00Z", + last_used: "2026-01-02T00:00:00Z", + is_current: true, + }; + mockFetch.mockResolvedValue(jsonResponse({ sessions: [session] })); + const result = await api.getSessions(); + expect(result).toEqual([session]); + }); + it("revokeSession calls DELETE with session ID", async () => { mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); await api.revokeSession(42); @@ -754,23 +773,6 @@ describe("API Client", () => { }); }); - describe("sound endpoints", () => { - it("getSounds calls GET /sounds", async () => { - mockFetch.mockResolvedValue(jsonResponse([{ id: 1, name: "beep" }])); - const result = await api.getSounds(); - expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/sounds"); - expect(fetchCallOpts().method).toBe("GET"); - expect(result).toEqual([{ id: 1, name: "beep" }]); - }); - - it("deleteSound calls DELETE /sounds/{id}", async () => { - mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); - await api.deleteSound(3); - expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/sounds/3"); - expect(fetchCallOpts().method).toBe("DELETE"); - }); - }); - describe("DM endpoints", () => { it("getDmChannels calls GET /dms", async () => { mockFetch.mockResolvedValue(jsonResponse({ channels: [] })); @@ -1033,7 +1035,7 @@ describe("API Client", () => { describe("doFetch body serialization", () => { it("omits body when body is undefined (GET requests)", async () => { - mockFetch.mockResolvedValue(jsonResponse([])); + mockFetch.mockResolvedValue(jsonResponse({ sessions: [] })); await api.getSessions(); expect(fetchCallOpts().body).toBeUndefined(); }); diff --git a/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet.test.ts index 8b7412e9..966fd73d 100644 --- a/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet.test.ts +++ b/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet.test.ts @@ -455,6 +455,93 @@ describe("AudioPipeline", () => { // Worklet should NOT have been started (generation mismatch) expect(pipeline.vadUsingWorklet).toBe(false); }); + + it("discards worklet addModule result if stopVadPolling is called without a teardown", async () => { + // Same setup as above, but this time only stopVadPolling() runs (e.g. the + // user set sensitivity to 100) — teardownAudioPipeline() is NOT called, so + // the pipeline (and _pipelineGeneration) stays intact. The in-flight + // addModule from the earlier startVadPolling must still be invalidated by + // its own generation, or it resurrects VAD with the stale threshold. + let resolveAddModule: () => void; + const addModulePromise = new Promise<void>((resolve) => { + resolveAddModule = resolve; + }); + + const mockAnalyser = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + const mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + const mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue(mockAnalyser), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockReturnValue(addModulePromise) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => ({ + port: { postMessage: vi.fn(), onmessage: null }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + ); + + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "voiceSensitivity") return 50; + if (key === "inputVolume") return 100; + return defaultVal; + }); + + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "t" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + // Stop VAD (not a full teardown) while addModule is still in flight — + // e.g. the user dragged sensitivity to 100. + pipeline.stopVadPolling(); + + // Now resolve addModule — should be discarded because stopVadPolling + // invalidated the in-flight call. + resolveAddModule!(); + await addModulePromise; + + // Yield to microtasks + await new Promise((r) => setTimeout(r, 0)); + + // Worklet should NOT have been (re)started — VAD is meant to be off. + expect(pipeline.vadUsingWorklet).toBe(false); + }); }); describe("worklet gate message deduplication", () => { diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts index cb072647..c718696c 100644 --- a/Client/tauri-client/tests/unit/auth.store.test.ts +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -7,9 +7,16 @@ import { getCurrentUser, updateUser, } from "../../src/stores/auth.store"; -import { resetVoiceStore, joinVoiceChannel, setVoiceStatus } from "../../src/stores/voice.store"; +import { + voiceStore, + resetVoiceStore, + joinVoiceChannel, + setVoiceStatus, +} from "../../src/stores/voice.store"; import { leaveVoice } from "@lib/livekitSession"; -import type { UserWithRole } from "../../src/lib/types"; +import { setMessages, isChannelLoaded, getChannelMessages } from "../../src/stores/messages.store"; +import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate"; +import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types"; // Mock the lazily-imported voice SDK module so we can assert clearAuth() only // pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists. @@ -120,6 +127,19 @@ describe("auth store", () => { expect(before).not.toBe(after); }); + // v076: acknowledgements are per-viewer consent, not per-device. Host + // scoping cannot cover a second account on the SAME server, so the age + // gate must be re-armed on logout or the next user silently inherits it. + it("clears NSFW acknowledgements so the next account re-sees the age gate", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + acknowledgeNsfw(12); + expect(isNsfwAcknowledged(12)).toBe(true); + + clearAuth(); + + expect(isNsfwAcknowledged(12)).toBe(false); + }); + it("records 'user' as the default logout reason", () => { setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); clearAuth(); @@ -253,6 +273,31 @@ describe("auth store", () => { }); }); + // clearAuth's logoutWasInVoice snapshot — main.ts's isAuthenticated + // subscriber gates its voice_leave send on this instead of re-reading + // voiceStore, which clearAuth has already reset by the time any subscriber + // observes the transition (store notifications are microtask-deferred). + describe("clearAuth logoutWasInVoice snapshot", () => { + beforeEach(() => { + resetVoiceStore(); + }); + + it("is false when not in a voice channel at logout", () => { + clearAuth(); + expect(authStore.getState().logoutWasInVoice).toBe(false); + }); + + it("snapshots true when in a voice channel, surviving clearAuth's own voiceStore reset", () => { + joinVoiceChannel(7); + clearAuth(); + + expect(authStore.getState().logoutWasInVoice).toBe(true); + // The snapshot must reflect voice state as it was BEFORE this same + // call reset it — not the (already-idle) state read afterward. + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + }); + // 6. Subscribe receives updates on setAuth/clearAuth describe("subscribe", () => { it("notifies on setAuth", () => { @@ -324,4 +369,52 @@ describe("auth store", () => { unsubB(); }); }); + + // Regression: clearAuth() must also drop messagesStore, or a channel id + // that also exists on the next-signed-into server (channel ids are only + // unique per-server) renders the previous session's cached messages and + // never refetches, because MessageController.loadMessages short-circuits + // on isChannelLoaded. + describe("clearAuth messages cleanup", () => { + const AUTHOR: MessageUser = { id: 1, username: "alice", avatar: "alice.png" }; + + function makeMessageResponse(overrides?: Partial<MessageResponse>): MessageResponse { + return { + id: 1, + channel_id: 1, + user: AUTHOR, + content: "pre-logout message", + reply_to: null, + attachments: [], + reactions: [], + pinned: false, + edited_at: null, + deleted: false, + timestamp: "2026-03-15T10:00:00Z", + ...overrides, + }; + } + + it("clears cached messages and the loaded flag on logout", () => { + setMessages(1, [makeMessageResponse()], false); + expect(isChannelLoaded(1)).toBe(true); + expect(getChannelMessages(1)).toHaveLength(1); + + clearAuth(); + + expect(isChannelLoaded(1)).toBe(false); + expect(getChannelMessages(1)).toHaveLength(0); + }); + + it("does not leak the previous session's message content into the next", () => { + setMessages(1, [makeMessageResponse({ content: "server A secret" })], false); + clearAuth(); + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + + // Same numeric channel id, different server: must come back empty and + // unloaded so the caller refetches instead of rendering stale content. + expect(isChannelLoaded(1)).toBe(false); + expect(getChannelMessages(1)).toHaveLength(0); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/auto-idle.test.ts b/Client/tauri-client/tests/unit/auto-idle.test.ts index 984f72f4..8aff0541 100644 --- a/Client/tauri-client/tests/unit/auto-idle.test.ts +++ b/Client/tauri-client/tests/unit/auto-idle.test.ts @@ -180,6 +180,24 @@ describe("startAutoIdle", () => { expect(loadUserStatus()).toBe("idle"); }); + it("restores Online on activity when the controller starts from a persisted auto-idle state", () => { + // Regression for v023: a session that starts already auto-idle (app + // restart, MainPage remount) must still be un-idled by activity. The + // in-memory latch used to start false regardless of the persisted + // status/origin, so apply(false) was unreachable and the user broadcast + // "idle" for the rest of the session no matter how much they used the app. + saveUserStatus("idle", "auto"); + const onStatusChange = vi.fn(); + const target = createTarget(); + controller = startAutoIdle({ onStatusChange, target }); + + target.fire(); + + expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("online"); + expect(loadUserStatus()).toBe("online"); + expect(loadUserStatusOrigin()).toBe("manual"); + }); + it("stops firing after destroy", () => { saveUserStatus("online"); const onStatusChange = vi.fn(); diff --git a/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts b/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts index 9e431019..d9996c88 100644 --- a/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts +++ b/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts @@ -34,4 +34,75 @@ describe("createCertFirstUseModal (F4/F8)", () => { modal.destroy?.(); expect(container.querySelector(".modal-overlay")).toBeNull(); }); + + it("carries dialog semantics labelled by the h3 title", () => { + const modal = createCertFirstUseModal({ + host: "example.com:8443", + fingerprint: "aa:bb:cc:dd:ee:ff", + onAccept: vi.fn(), + onReject: vi.fn(), + }); + + const container = document.createElement("div"); + modal.mount(container); + + const dialog = container.querySelector(".modal"); + expect(dialog?.getAttribute("role")).toBe("dialog"); + expect(dialog?.getAttribute("aria-modal")).toBe("true"); + expect(dialog?.getAttribute("aria-labelledby")).toBe("cert-first-use-title"); + expect(container.querySelector("#cert-first-use-title")?.textContent).toBe( + "New Server Certificate", + ); + expect(container.querySelector(".modal-close")?.getAttribute("aria-label")).toBe("Close"); + + modal.destroy?.(); + }); + + it("rejects on Escape while mounted, but not after destroy", () => { + const onReject = vi.fn(); + const modal = createCertFirstUseModal({ + host: "example.com:8443", + fingerprint: "aa:bb:cc:dd:ee:ff", + onAccept: vi.fn(), + onReject, + }); + + // Escape listens on document and guards on overlay.isConnected, so the + // container has to actually be in the document here. + const container = document.createElement("div"); + document.body.appendChild(container); + modal.mount(container); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).toHaveBeenCalledTimes(1); + + modal.destroy?.(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).toHaveBeenCalledTimes(1); + container.remove(); + }); + + it("moves focus inside the modal on mount and restores it on destroy", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + + const modal = createCertFirstUseModal({ + host: "example.com:8443", + fingerprint: "aa:bb:cc:dd:ee:ff", + onAccept: vi.fn(), + onReject: vi.fn(), + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + modal.mount(container); + + expect(container.contains(document.activeElement)).toBe(true); + + modal.destroy?.(); + expect(document.activeElement).toBe(outside); + container.remove(); + outside.remove(); + }); }); diff --git a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts index 85538869..0dc30704 100644 --- a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts +++ b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts @@ -145,4 +145,44 @@ describe("CertMismatchModal", () => { const title = container.querySelector(".cert-title"); expect(title?.textContent).toBe("Certificate Changed"); }); + + it("carries dialog semantics labelled by the h3 title", () => { + mountModal(); + const modal = container.querySelector(".modal"); + expect(modal?.getAttribute("role")).toBe("dialog"); + expect(modal?.getAttribute("aria-modal")).toBe("true"); + expect(modal?.getAttribute("aria-labelledby")).toBe("cert-mismatch-title"); + const title = container.querySelector("#cert-mismatch-title"); + expect(title?.textContent).toBe("Certificate Warning"); + }); + + it("gives the icon-only close button an accessible name", () => { + mountModal(); + const btn = container.querySelector(".modal-close"); + expect(btn?.getAttribute("aria-label")).toBe("Close"); + }); + + it("calls onReject when Escape is pressed", () => { + const { onReject } = mountModal(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("does not call onReject on Escape after destroy", () => { + const { modal, onReject } = mountModal(); + modal.destroy?.(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("moves focus inside the modal on mount and restores it on destroy", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + const { modal } = mountModal(); + expect(container.contains(document.activeElement)).toBe(true); + modal.destroy?.(); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); }); diff --git a/Client/tauri-client/tests/unit/cert-reconnect.test.ts b/Client/tauri-client/tests/unit/cert-reconnect.test.ts new file mode 100644 index 00000000..432055e4 --- /dev/null +++ b/Client/tauri-client/tests/unit/cert-reconnect.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from "vitest"; +import { reconnectAfterCertAccept } from "../../src/lib/cert-reconnect"; + +function fakeWs() { + const listeners = new Set<(state: string) => void>(); + return { + connect: vi.fn(), + onStateChange: vi.fn((l: (state: string) => void) => { + listeners.add(l); + return () => listeners.delete(l); + }), + fire(state: string): void { + for (const l of listeners) l(state); + }, + }; +} + +describe("reconnectAfterCertAccept", () => { + it("reconnects and navigates to main once the state change reaches 'connected'", () => { + const ws = fakeWs(); + const router = { getCurrentPage: vi.fn(() => "connect"), navigate: vi.fn() }; + + reconnectAfterCertAccept(ws, router, "h.example", "tok"); + + expect(ws.connect).toHaveBeenCalledWith({ host: "h.example", token: "tok" }); + expect(router.navigate).not.toHaveBeenCalled(); + + ws.fire("connecting"); + expect(router.navigate).not.toHaveBeenCalled(); + + ws.fire("connected"); + expect(router.navigate).toHaveBeenCalledWith("main"); + expect(router.navigate).toHaveBeenCalledTimes(1); + }); + + it("unsubscribes after firing once, so a later state change does not navigate twice", () => { + const ws = fakeWs(); + const router = { getCurrentPage: vi.fn(() => "connect"), navigate: vi.fn() }; + + reconnectAfterCertAccept(ws, router, "h.example", "tok"); + ws.fire("connected"); + ws.fire("connected"); + + expect(router.navigate).toHaveBeenCalledTimes(1); + }); + + it("does not register a navigator when already on the main page", () => { + const ws = fakeWs(); + const router = { getCurrentPage: vi.fn(() => "main"), navigate: vi.fn() }; + + reconnectAfterCertAccept(ws, router, "h.example", "tok"); + + expect(ws.onStateChange).not.toHaveBeenCalled(); + expect(ws.connect).toHaveBeenCalledWith({ host: "h.example", token: "tok" }); + }); +}); diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 98830d93..7d4aa425 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -121,11 +121,18 @@ const { mockSetMessagePinned, mockAddOptimistic, mockMarkSendFailed, mockRemoveO mockRemoveOptimistic: vi.fn(), })); +const { mockMarkChannelRead } = vi.hoisted(() => ({ mockMarkChannelRead: vi.fn() })); + +vi.mock("@lib/read-state", () => ({ + markChannelRead: mockMarkChannelRead, +})); + const { mockRole } = vi.hoisted(() => ({ mockRole: { value: "member" } })); -const { mockReattachToPresent, mockJumpToMessage } = vi.hoisted(() => ({ +const { mockReattachToPresent, mockJumpToMessage, mockIsWindowDetached } = vi.hoisted(() => ({ mockReattachToPresent: vi.fn(), mockJumpToMessage: vi.fn(), + mockIsWindowDetached: vi.fn(() => false), })); vi.mock("@stores/messages.store", () => ({ @@ -135,6 +142,7 @@ vi.mock("@stores/messages.store", () => ({ markSendFailed: mockMarkSendFailed, removeOptimistic: mockRemoveOptimistic, reattachToPresent: mockReattachToPresent, + isWindowDetached: mockIsWindowDetached, })); vi.mock("@lib/message-navigation", () => ({ @@ -155,7 +163,12 @@ vi.mock("../../src/pages/main-page/ChatHeader", () => ({ updateChatHeaderForDm: mockUpdateChatHeaderForDm, })); -const { mockDmStoreGetState, mockMembersStoreGetState } = vi.hoisted(() => ({ +const { + mockDmStoreGetState, + mockMembersStoreGetState, + dmStoreSubscribers, + membersStoreSubscribers, +} = vi.hoisted(() => ({ mockDmStoreGetState: vi.fn(() => ({ channels: [] as Array<{ channelId: number; @@ -171,6 +184,8 @@ const { mockDmStoreGetState, mockMembersStoreGetState } = vi.hoisted(() => ({ }>, })), mockMembersStoreGetState: vi.fn(() => ({ members: new Map() })), + dmStoreSubscribers: [] as Array<() => void>, + membersStoreSubscribers: [] as Array<() => void>, })); vi.mock("@stores/dm.store", async () => { @@ -179,13 +194,25 @@ vi.mock("@stores/dm.store", async () => { // in a test while agreeing in production. const actual = await vi.importActual<typeof import("@stores/dm.store")>("@stores/dm.store"); return { - dmStore: { getState: mockDmStoreGetState }, + dmStore: { + getState: mockDmStoreGetState, + subscribeSelector: vi.fn((_sel: unknown, cb: () => void) => { + dmStoreSubscribers.push(cb); + return () => {}; + }), + }, dmDisplayName: actual.dmDisplayName, }; }); vi.mock("@stores/members.store", () => ({ - membersStore: { getState: mockMembersStoreGetState }, + membersStore: { + getState: mockMembersStoreGetState, + subscribeSelector: vi.fn((_sel: unknown, cb: () => void) => { + membersStoreSubscribers.push(cb); + return () => {}; + }), + }, })); // Block-state gating: capture the subscription callback so tests can simulate a @@ -270,6 +297,8 @@ describe("createChannelController", () => { capturedMessageListOpts = null; capturedMessageInputOpts = null; blocksSubscribers.length = 0; + dmStoreSubscribers.length = 0; + membersStoreSubscribers.length = 0; mockDmComposerBlockReason.mockReturnValue(null); // The controller gates sends on the store-backed connection status // (docs/architecture/ux §3), not on ws.getState(). @@ -342,6 +371,30 @@ describe("createChannelController", () => { expect(ctrl.currentChannelId).toBe(99); }); + it("marks the previous channel read when switching away from it", () => { + // channel_focus only advances read state for the channel being entered; + // nothing advances it for the channel being left, so leaving must mark it + // read explicitly or its badges come back stale on the next `ready`. + const opts = makeOpts(); + const ctrl = createChannelController(opts); + + ctrl.mountChannel(42, "general"); + expect(mockMarkChannelRead).not.toHaveBeenCalled(); + + ctrl.mountChannel(99, "random"); + + expect(mockMarkChannelRead).toHaveBeenCalledWith(42); + }); + + it("does not mark anything read on the very first mount (no previous channel)", () => { + const opts = makeOpts(); + const ctrl = createChannelController(opts); + + ctrl.mountChannel(42, "general"); + + expect(mockMarkChannelRead).not.toHaveBeenCalled(); + }); + it("updates chat header name", () => { const opts = makeOpts(); const ctrl = createChannelController(opts); @@ -421,6 +474,24 @@ describe("createChannelController", () => { }); }); + it("onSend from a detached window reattaches to present before sending", () => { + // After a jump into history the composer stays enabled; sending must + // land the optimistic row in the live tail, not mid-history. + mockIsWindowDetached.mockReturnValueOnce(true); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + (opts.msgCtrl.loadMessages as ReturnType<typeof vi.fn>).mockClear(); + + capturedMessageInputOpts.onSend("hello", null, []); + + expect(mockReattachToPresent).toHaveBeenCalledWith(42); + // Reattach clears "loaded", so the live tail is refetched. + expect(opts.msgCtrl.loadMessages).toHaveBeenCalledWith(42, expect.any(AbortSignal)); + // The send itself still goes out. + expect(opts.ws.send).toHaveBeenCalledWith(expect.objectContaining({ type: "chat_send" })); + }); + it("onSend while disconnected records a failed optimistic row (no silent drop)", () => { const opts = makeOpts(); setConnectionStatus("disconnected"); @@ -525,6 +596,59 @@ describe("createChannelController", () => { expect((opts.ws.send as ReturnType<typeof vi.fn>).mock.calls.length).toBe(sendCalls); }); + it("onRetry still re-sends a failed draft after the channel was remounted", () => { + // A failed row survives a channel switch (it is carried across history + // refetches), so its draft must too — a per-mount draft map left Retry + // silently inert once the reader switched away and back. + const opts = makeOpts(); + let n = 0; + (opts.ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => `cid-${++n}`); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + + // cid-1 is channel_focus; the chat_send gets cid-2. + capturedMessageInputOpts.onSend("hello", null, []); + expect(mockAddOptimistic).toHaveBeenCalledWith( + expect.objectContaining({ correlationId: "cid-2", content: "hello" }), + ); + + // Simulate a real remount: switch away and back to the same channel. + ctrl.destroyChannel(); + ctrl.mountChannel(42, "general"); + + capturedMessageListOpts.onRetry("cid-2"); + + expect(mockRemoveOptimistic).toHaveBeenCalledWith("cid-2"); + expect(mockAddOptimistic).toHaveBeenLastCalledWith( + expect.objectContaining({ correlationId: "cid-4", content: "hello" }), + ); + }); + + it("releases a draft once the send is acked", () => { + // The draft map outlives a channel switch so a failed row stays + // retriable. Nothing else prunes it, so an accepted send must release + // its own entry or every message of the session is retained forever. + const opts = makeOpts(); + let n = 0; + (opts.ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => `cid-${++n}`); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + + capturedMessageInputOpts.onSend("hello", null, []); + + const ackCall = (opts.ws.on as ReturnType<typeof vi.fn>).mock.calls.find( + (c: unknown[]) => c[0] === "chat_send_ok", + ); + const onAck = ackCall![1] as (payload: unknown, id?: string) => void; + onAck({ message_id: 7, timestamp: "2024-01-01T00:00:00Z" }, "cid-2"); + + const sendCalls = (opts.ws.send as ReturnType<typeof vi.fn>).mock.calls.length; + capturedMessageListOpts.onRetry("cid-2"); + + // No draft left to re-send: the ack already released it. + expect((opts.ws.send as ReturnType<typeof vi.fn>).mock.calls.length).toBe(sendCalls); + }); + it("onTyping sends typing_start via ws", () => { const opts = makeOpts(); const ctrl = createChannelController(opts); @@ -922,6 +1046,100 @@ describe("createChannelController", () => { }); }); + it("keeps the DM header subtitle live when the partner's presence changes", () => { + mockDmStoreGetState.mockReturnValue({ + channels: [ + { + channelId: 42, + recipient: { id: 5, username: "alice", avatar: "", status: "offline" }, + participants: [{ id: 5, username: "alice", avatar: "", status: "offline" }], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + }, + ], + }); + mockMembersStoreGetState.mockReturnValue({ members: new Map() }); + + const chatHeaderRefs = { + hashEl: document.createElement("span"), + nameEl: document.createElement("span"), + topicEl: document.createElement("span"), + callBtn: document.createElement("button"), + }; + const opts = makeOpts({ chatHeaderRefs }); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "alice", "dm"); + + expect(mockUpdateChatHeaderForDm).toHaveBeenLastCalledWith(chatHeaderRefs, { + username: "alice", + status: "Offline", + }); + + // The partner comes online — no re-mount, just a members store update. + mockMembersStoreGetState.mockReturnValue({ + members: new Map([[5, { id: 5, username: "alice", status: "online" }]]), + }); + for (const cb of membersStoreSubscribers) cb(); + + expect(mockUpdateChatHeaderForDm).toHaveBeenLastCalledWith(chatHeaderRefs, { + username: "alice", + status: "Online", + }); + }); + + it("keeps a group DM header live when the roster changes", () => { + const dmChannel = { + channelId: 42, + recipient: { id: 5, username: "alice", avatar: "", status: "online" }, + participants: [{ id: 5, username: "alice", avatar: "", status: "online" }], + name: "", + isGroup: true, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + }; + mockDmStoreGetState.mockReturnValue({ channels: [dmChannel] }); + + const chatHeaderRefs = { + hashEl: document.createElement("span"), + nameEl: document.createElement("span"), + topicEl: document.createElement("span"), + callBtn: document.createElement("button"), + }; + const opts = makeOpts({ chatHeaderRefs }); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "Group", "dm"); + + expect(mockUpdateChatHeaderForDm).toHaveBeenLastCalledWith( + chatHeaderRefs, + expect.objectContaining({ status: "2 members: You, alice" }), + ); + + // Someone else joins the group — no re-mount, just a dm store update. + mockDmStoreGetState.mockReturnValue({ + channels: [ + { + ...dmChannel, + participants: [ + { id: 5, username: "alice", avatar: "", status: "online" }, + { id: 6, username: "bob", avatar: "", status: "online" }, + ], + }, + ], + }); + for (const cb of dmStoreSubscribers) cb(); + + expect(mockUpdateChatHeaderForDm).toHaveBeenLastCalledWith( + chatHeaderRefs, + expect.objectContaining({ status: "3 members: You, alice, bob" }), + ); + }); + it("resets header for non-DM channel when chatHeaderRefs is provided", () => { const chatHeaderRefs = { hashEl: document.createElement("span"), diff --git a/Client/tauri-client/tests/unit/channel-mutes.test.ts b/Client/tauri-client/tests/unit/channel-mutes.test.ts index b85ae41e..102efb8e 100644 --- a/Client/tauri-client/tests/unit/channel-mutes.test.ts +++ b/Client/tauri-client/tests/unit/channel-mutes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { isChannelMuted, listMutedChannels, @@ -7,6 +7,7 @@ import { toggleChannelMute, notificationAllowed, invalidateMuteCache, + setChannelMutesHost, } from "@lib/channel-mutes"; import { STORAGE_PREFIX } from "@lib/preferences"; @@ -76,6 +77,54 @@ describe("channel mutes — storage", () => { }); }); +describe("channel mutes — host scoping", () => { + afterEach(() => { + // currentHost is module-level state that outlives a single test. + setChannelMutesHost(null); + }); + + it("does not leak a mute across two server hosts", () => { + // Regression for v047: channel ids are per-server, so an unscoped key + // meant muting channel 7 on one server silently muted channel 7 on every + // other server too. + setChannelMutesHost("a.example.com"); + muteChannel(7); + expect(isChannelMuted(7)).toBe(true); + + setChannelMutesHost("b.example.com"); + expect(isChannelMuted(7)).toBe(false); + + setChannelMutesHost("a.example.com"); + expect(isChannelMuted(7)).toBe(true); + }); + + it("persists each host's mutes under a distinct localStorage key", () => { + setChannelMutesHost("a.example.com"); + muteChannel(1); + setChannelMutesHost("b.example.com"); + muteChannel(2); + + expect( + JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}mutedChannels:a.example.com`)!), + ).toEqual([1]); + expect( + JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}mutedChannels:b.example.com`)!), + ).toEqual([2]); + }); + + it("switching to the same host is a no-op that keeps the cache", () => { + setChannelMutesHost("a.example.com"); + muteChannel(3); + setChannelMutesHost("a.example.com"); + expect(isChannelMuted(3)).toBe(true); + }); + + it("falls back to the legacy unscoped key when no host has been set", () => { + muteChannel(9); + expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual([9]); + }); +}); + describe("channel mutes — notification gating", () => { it("allows notifications for an unmuted channel", () => { expect(notificationAllowed(1, false)).toBe(true); diff --git a/Client/tauri-client/tests/unit/channel-navigation.test.ts b/Client/tauri-client/tests/unit/channel-navigation.test.ts new file mode 100644 index 00000000..9e81b15a --- /dev/null +++ b/Client/tauri-client/tests/unit/channel-navigation.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + navigateToChannel, + findChannelById, + findChannelByName, +} from "../../src/lib/channel-navigation"; +import { channelsStore } from "../../src/stores/channels.store"; +import type { Channel } from "../../src/stores/channels.store"; +import { dmStore, setDmChannels } from "../../src/stores/dm.store"; +import type { DmChannel } from "../../src/stores/dm.store"; + +function makeChannel(overrides: Partial<Channel> = {}): Channel { + return { + id: 1, + name: "general", + type: "text", + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + slowMode: 0, + topic: "", + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + ...overrides, + }; +} + +function makeDm(overrides: Partial<DmChannel> = {}): DmChannel { + return { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [{ id: 10, username: "bob", avatar: "", status: "online" }], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 3, + mentionCount: 1, + ...overrides, + }; +} + +describe("channel-navigation", () => { + beforeEach(() => { + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + dmStore.setState(() => ({ channels: [] })); + }); + + describe("navigateToChannel", () => { + it("activates the channel and clears its channelsStore unread badge", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, makeChannel({ id: 1, unreadCount: 5, mentionCount: 2 })); + return { ...prev, channels: next }; + }); + + navigateToChannel(1); + + expect(channelsStore.getState().activeChannelId).toBe(1); + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); + expect(channelsStore.getState().channels.get(1)?.mentionCount).toBe(0); + }); + + it("is a no-op for a channel id the store does not know", () => { + navigateToChannel(999); + expect(channelsStore.getState().activeChannelId).toBeNull(); + }); + + // Regression: a jump (permalink, search, pinned, reply) can land on a + // `type: "dm"` mirror row synthesized into channelsStore — findChannelById + // does not filter those out. The DM's real unread badge lives in dmStore, + // not channelsStore, so clearing only the mirror leaves the DM sidebar + // row lit while the user is actively reading it. + it("also clears the dmStore unread/mention badge for a DM channel id", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(50, makeChannel({ id: 50, name: "bob", type: "dm", unreadCount: 3 })); + return { ...prev, channels: next }; + }); + setDmChannels([makeDm({ channelId: 50, unreadCount: 3, mentionCount: 1 })]); + + navigateToChannel(50); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.unreadCount).toBe(0); + expect(dm?.mentionCount).toBe(0); + }); + + it("does not touch dmStore for a non-DM channel id", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, makeChannel({ id: 1 })); + return { ...prev, channels: next }; + }); + setDmChannels([makeDm({ channelId: 50, unreadCount: 3 })]); + + navigateToChannel(1); + + expect(dmStore.getState().channels.find((c) => c.channelId === 50)?.unreadCount).toBe(3); + }); + }); + + describe("findChannelById", () => { + it("returns the channel when known", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, makeChannel({ id: 1, name: "general" })); + return { ...prev, channels: next }; + }); + expect(findChannelById(1)).toEqual({ id: 1, name: "general" }); + }); + + it("returns null for an unknown id", () => { + expect(findChannelById(999)).toBeNull(); + }); + }); + + describe("findChannelByName", () => { + it("resolves case-insensitively", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, makeChannel({ id: 1, name: "General" })); + return { ...prev, channels: next }; + }); + expect(findChannelByName("general")).toEqual({ id: 1, name: "General" }); + }); + + it("excludes DM channels", () => { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(50, makeChannel({ id: 50, name: "bob", type: "dm" })); + return { ...prev, channels: next }; + }); + expect(findChannelByName("bob")).toBeNull(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index fe5cfa09..2de027e8 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -1872,6 +1872,20 @@ describe("ChannelSidebar voice identity badge", () => { expect(badge!.classList.contains("mismatch")).toBe(true); }); + it("shows a distinct 'could not check' badge when the pin store was unreadable (DC-08)", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "unknown", null); + sidebar.mount(container); + + const badge = badgeFor(10); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains("unknown")).toBe(true); + // Must not read as the legacy "no key published" state — the message is + // about local storage failing, not about the peer. + expect(badge!.classList.contains("unverified")).toBe(false); + expect(badge!.getAttribute("title")).toContain("Could not check"); + }); + it("shows no badge when the peer's verification is unresolved", () => { addVoiceUser(VOICE_CH, 10, "Alice"); sidebar.mount(container); diff --git a/Client/tauri-client/tests/unit/channels.store.test.ts b/Client/tauri-client/tests/unit/channels.store.test.ts index 63e84670..1d4ee7f5 100644 --- a/Client/tauri-client/tests/unit/channels.store.test.ts +++ b/Client/tauri-client/tests/unit/channels.store.test.ts @@ -112,6 +112,38 @@ describe("channels store", () => { expect(ch?.unreadCount).toBe(0); expect(ch?.lastMessageId).toBeNull(); }); + + it("carries synthesized DM rows across a ready rebuild", () => { + setChannels(readyChannels); + // A DM row is synthesized client-side (selectDmConversation); the ready + // payload never restates it, so a rebuild must not destroy it. + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(99, { + id: 99, + name: "bob", + type: "dm", + category: null, + topic: "", + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: next }; + }); + + setChannels(readyChannels); + + const dm = channelsStore.getState().channels.get(99); + expect(dm?.type).toBe("dm"); + expect(dm?.name).toBe("bob"); + }); }); describe("addChannel", () => { @@ -160,6 +192,68 @@ describe("channels store", () => { expect(before.size).toBe(3); expect(after.size).toBe(4); }); + + it("preserves per-user fields on a re-sent channel_create (idempotent add)", () => { + // The server re-broadcasts channel_create on role/override edits with + // the note "Idempotent add on the client" — the broadcast carries no + // per-user fields, so a re-add must not reset them. + setChannels(readyChannels); // channel 1: unreadCount 3, lastMessageId 100 + incrementMention(1); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, { ...prev.channels.get(1)!, canSend: false }); + return { ...prev, channels: next }; + }); + + addChannel({ id: 1, name: "general-renamed", type: "text", category: "Text", position: 0 }); + + const ch = channelsStore.getState().channels.get(1)!; + expect(ch.name).toBe("general-renamed"); // payload fields still apply + expect(ch.unreadCount).toBe(3); + expect(ch.mentionCount).toBe(1); + expect(ch.lastMessageId).toBe(100); + expect(ch.canSend).toBe(false); + }); + + // v017: can_send used to be computed only in the ready payload, so a role + // or override edit left every connected client's composer on its stale + // connect-time verdict until the socket was rebuilt. The targeted + // channel_create from RefreshChannelVisibility now carries this viewer's + // own verdict, and it must win over the retained value. + it("applies can_send from a targeted channel_create", () => { + setChannels(readyChannels); // channel 1 starts canSend: true + + addChannel({ + id: 1, + name: "general", + type: "text", + category: "Text", + position: 0, + can_send: false, + }); + + expect(channelsStore.getState().channels.get(1)!.canSend).toBe(false); + }); + + it("re-grants can_send when a permission edit restores posting", () => { + setChannels(readyChannels); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(1, { ...prev.channels.get(1)!, canSend: false }); + return { ...prev, channels: next }; + }); + + addChannel({ + id: 1, + name: "general", + type: "text", + category: "Text", + position: 0, + can_send: true, + }); + + expect(channelsStore.getState().channels.get(1)!.canSend).toBe(true); + }); }); describe("updateChannel", () => { diff --git a/Client/tauri-client/tests/unit/components/QuickSwitchOverlay.test.ts b/Client/tauri-client/tests/unit/components/QuickSwitchOverlay.test.ts index 8b85a426..3db82f1d 100644 --- a/Client/tauri-client/tests/unit/components/QuickSwitchOverlay.test.ts +++ b/Client/tauri-client/tests/unit/components/QuickSwitchOverlay.test.ts @@ -77,4 +77,103 @@ describe("QuickSwitchOverlay", () => { expect(onClose).toHaveBeenCalled(); overlay.destroy?.(); }); + + it("applies dialog semantics to the modal", () => { + const overlay = createQuickSwitchOverlay({ + profiles: [{ name: "My Server", host: "localhost:8443" }], + currentHost: "localhost:8443", + onSwitch: vi.fn(), + onAddServer: vi.fn(), + onClose: vi.fn(), + }); + overlay.mount(container); + const modal = container.querySelector(".quick-switch-modal"); + expect(modal?.getAttribute("role")).toBe("dialog"); + expect(modal?.getAttribute("aria-modal")).toBe("true"); + expect(modal?.getAttribute("aria-label")).toBe("Switch server"); + overlay.destroy?.(); + }); + + it("gives switchable items button semantics but keeps the current row inert", () => { + const overlay = createQuickSwitchOverlay({ + profiles: [ + { name: "Server A", host: "a:8443" }, + { name: "Server B", host: "b:8443" }, + ], + currentHost: "a:8443", + onSwitch: vi.fn(), + onAddServer: vi.fn(), + onClose: vi.fn(), + }); + overlay.mount(container); + const items = container.querySelectorAll("[data-testid='server-item']"); + // Current server row has no action, so it must not claim to be a button + expect(items[0]!.hasAttribute("role")).toBe(false); + expect(items[0]!.hasAttribute("tabindex")).toBe(false); + expect(items[1]!.getAttribute("role")).toBe("button"); + expect(items[1]!.getAttribute("tabindex")).toBe("0"); + const addBtn = container.querySelector("[data-testid='add-server-btn']"); + expect(addBtn?.getAttribute("role")).toBe("button"); + expect(addBtn?.getAttribute("tabindex")).toBe("0"); + overlay.destroy?.(); + }); + + it("Enter activates a server item", () => { + const onSwitch = vi.fn(); + const overlay = createQuickSwitchOverlay({ + profiles: [ + { name: "Server A", host: "a:8443" }, + { name: "Server B", host: "b:8443" }, + ], + currentHost: "a:8443", + onSwitch, + onAddServer: vi.fn(), + onClose: vi.fn(), + }); + overlay.mount(container); + const items = container.querySelectorAll("[data-testid='server-item']"); + items[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(onSwitch).toHaveBeenCalledWith("b:8443", "Server B"); + overlay.destroy?.(); + }); + + it("Space activates the add-server button", () => { + const onAddServer = vi.fn(); + const overlay = createQuickSwitchOverlay({ + profiles: [{ name: "My Server", host: "localhost:8443" }], + currentHost: "localhost:8443", + onSwitch: vi.fn(), + onAddServer, + onClose: vi.fn(), + }); + overlay.mount(container); + const addBtn = container.querySelector("[data-testid='add-server-btn']") as HTMLElement; + addBtn.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + expect(onAddServer).toHaveBeenCalledOnce(); + overlay.destroy?.(); + }); + + it("moves focus into the dialog on mount and restores it on destroy", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const overlay = createQuickSwitchOverlay({ + profiles: [ + { name: "Server A", host: "a:8443" }, + { name: "Server B", host: "b:8443" }, + ], + currentHost: "a:8443", + onSwitch: vi.fn(), + onAddServer: vi.fn(), + onClose: vi.fn(), + }); + overlay.mount(container); + const modal = container.querySelector(".quick-switch-modal"); + expect(modal?.contains(document.activeElement)).toBe(true); + + overlay.destroy?.(); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); }); diff --git a/Client/tauri-client/tests/unit/create-channel-modal.test.ts b/Client/tauri-client/tests/unit/create-channel-modal.test.ts index 4fb698e9..9e0cd3dd 100644 --- a/Client/tauri-client/tests/unit/create-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/create-channel-modal.test.ts @@ -330,4 +330,60 @@ describe("CreateChannelModal", () => { modal.destroy?.(); }); + + // ── dialog accessibility contract (DC-13) ────────────────────────────────── + + it("stamps dialog semantics named by the header title", () => { + const { modal } = makeModal("Text Channels"); + const dialog = container.querySelector(".modal") as HTMLElement; + expect(dialog.getAttribute("role")).toBe("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-labelledby")).toBe("create-channel-title"); + expect(container.querySelector("#create-channel-title")?.textContent).toBe("Create Channel"); + modal.destroy?.(); + }); + + it("labels the icon-only close button", () => { + const { modal } = makeModal("Text Channels"); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(closeBtn.getAttribute("aria-label")).toBe("Close"); + modal.destroy?.(); + }); + + it("Escape calls onClose without creating", () => { + const onCreate = vi.fn(async () => {}); + const onClose = vi.fn(); + const { modal } = makeModal("Text Channels", { onCreate, onClose }); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onCreate).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("ignores Escape after destroy", () => { + const onClose = vi.fn(); + const { modal } = makeModal("Text Channels", { onClose }); + modal.destroy?.(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("focuses the name input on open and restores focus on destroy", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const { modal } = makeModal("Text Channels"); + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; + expect(document.activeElement).toBe(nameInput); + + modal.destroy?.(); + expect(document.activeElement).toBe(trigger); + }); }); diff --git a/Client/tauri-client/tests/unit/credentials.test.ts b/Client/tauri-client/tests/unit/credentials.test.ts index 18401bf9..d1f0bf4c 100644 --- a/Client/tauri-client/tests/unit/credentials.test.ts +++ b/Client/tauri-client/tests/unit/credentials.test.ts @@ -9,6 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { authStore } from "@stores/auth.store"; const invoke = vi.fn(); @@ -16,7 +17,8 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) as unknown, })); -const { saveCredential, loadCredential, deleteCredential } = await import("@lib/credentials"); +const { saveCredential, loadCredential, deleteCredential, createUserUpdateCredentialSaver } = + await import("@lib/credentials"); beforeEach(() => { invoke.mockReset().mockResolvedValue(undefined); @@ -64,6 +66,63 @@ describe("saveCredential", () => { }); }); +// ── createUserUpdateCredentialSaver ────────────────────────────────────────── + +describe("createUserUpdateCredentialSaver", () => { + beforeEach(() => { + authStore.setState(() => ({ + token: "sess-token", + user: { id: 1, username: "alice", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + }); + + it("does not save when the session declined to remember the password (BUG-135)", () => { + const listener = createUserUpdateCredentialSaver("h.example", false, "s3cret"); + + listener({ user_id: 1, username: "alice2" }); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("saves the refreshed username with the session's password when opted in", async () => { + const listener = createUserUpdateCredentialSaver("h.example", true, "s3cret"); + + listener({ user_id: 1, username: "alice2" }); + + // saveCredential is fire-and-forget and itself awaits a dynamic import + // before calling invoke — wait for it rather than guessing a microtask + // count. + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith("save_credential", { + host: "h.example", + username: "alice2", + token: "sess-token", + password: "s3cret", + }); + }); + }); + + it("ignores a user_update for someone else", () => { + const listener = createUserUpdateCredentialSaver("h.example", true, "s3cret"); + + listener({ user_id: 999, username: "bob" }); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("is a no-op when there is no current session token", () => { + authStore.setState((prev) => ({ ...prev, token: null })); + const listener = createUserUpdateCredentialSaver("h.example", true, "s3cret"); + + listener({ user_id: 1, username: "alice2" }); + + expect(invoke).not.toHaveBeenCalled(); + }); +}); + // ── loadCredential ───────────────────────────────────────────────────────── describe("loadCredential", () => { diff --git a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts index fccfc32b..59af2296 100644 --- a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts @@ -157,4 +157,60 @@ describe("DeleteChannelModal", () => { expect(onClose).toHaveBeenCalled(); modal.destroy?.(); }); + + // ── dialog accessibility contract (DC-13) ────────────────────────────────── + + it("stamps dialog semantics named by the header title", () => { + const { modal } = makeModal(); + const dialog = container.querySelector(".modal") as HTMLElement; + expect(dialog.getAttribute("role")).toBe("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-labelledby")).toBe("delete-channel-title"); + expect(container.querySelector("#delete-channel-title")?.textContent).toBe("Delete Channel"); + modal.destroy?.(); + }); + + it("labels the icon-only close button", () => { + const { modal } = makeModal(); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(closeBtn.getAttribute("aria-label")).toBe("Close"); + modal.destroy?.(); + }); + + it("Escape cancels without confirming the delete", () => { + const onConfirm = vi.fn(async () => {}); + const onClose = vi.fn(); + const { modal } = makeModal({ onConfirm, onClose }); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("ignores Escape after destroy", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + modal.destroy?.(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("moves focus into the dialog on open and restores it on destroy", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const { modal } = makeModal(); + // First focusable is the header's close button — safely away from the + // destructive confirm. + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(document.activeElement).toBe(closeBtn); + + modal.destroy?.(); + expect(document.activeElement).toBe(trigger); + }); }); diff --git a/Client/tauri-client/tests/unit/device-manager.test.ts b/Client/tauri-client/tests/unit/device-manager.test.ts index 23c8a62f..74bb53e0 100644 --- a/Client/tauri-client/tests/unit/device-manager.test.ts +++ b/Client/tauri-client/tests/unit/device-manager.test.ts @@ -473,5 +473,49 @@ describe("DeviceManager", () => { // Enumerate failed, so no device switch should have been attempted expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); + + it("ignores a stale enumeration result when the room is swapped mid-await (v096)", async () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "audioInputDevice") return "saved-device-id"; + if (key === "audioOutputDevice") return ""; + return defaultVal; + }); + + let resolveDevices: ((devices: Array<{ deviceId: string }>) => void) | null = null; + mockGetLocalDevices.mockImplementation((kind: string) => { + if (kind === "audioinput") { + return new Promise((resolve) => { + resolveDevices = resolve; + }); + } + return Promise.resolve([]); + }); + + dm.setRoom(mockRoom); + const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; + handler(); + // Fire the debounce — handleDeviceChange starts and suspends on the + // still-pending getLocalDevices("audioinput") call. + await vi.advanceTimersByTimeAsync(500); + expect(resolveDevices).not.toBeNull(); + + // A system-driven reconnect (LiveKit Disconnected -> syncModuleRooms) + // swaps in a fresh Room while enumeration is still in flight. + const newRoom = { + localParticipant: { setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined) }, + switchActiveDevice: vi.fn().mockResolvedValue(undefined), + } as any; + dm.setRoom(newRoom); + + // The saved device is missing from this (stale) result, which would + // normally trigger a fallback. + resolveDevices!([{ deviceId: "other-device" }]); + await vi.advanceTimersByTimeAsync(0); + + // The stale attempt must not act on either the old room (it isn't + // "current" anymore) or the new one (this attempt was never for it). + expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(newRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + }); }); }); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 37f5df38..3666d13d 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -6,6 +6,7 @@ import { channelsStore, setRoles, getRoleIdByName } from "../../src/stores/chann import { messagesStore, addOptimisticMessage, + addOptimisticReaction, getChannelMessages, } from "../../src/stores/messages.store"; import { membersStore } from "../../src/stores/members.store"; @@ -26,7 +27,8 @@ import { loadReactionUsers, setReactionUsersFetcher, } from "../../src/components/message-list/reaction-tooltip"; -import type { WsClient, WsListener } from "../../src/lib/ws"; +import { setMarkReadSender } from "../../src/lib/read-state"; +import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws"; import type { ServerMessage } from "../../src/lib/types"; // Mock notifications and livekitSession to avoid side effects @@ -44,6 +46,7 @@ vi.mock("@lib/livekitSession", () => ({ isVoiceConnected: vi.fn(() => false), setMuted: vi.fn(), setDeafened: vi.fn(), + disableCamera: vi.fn(async () => {}), })); // F3: the ready handler publishes our identity key. Mock the orchestrator so // the wiring is asserted without real keygen/keyring. @@ -59,7 +62,12 @@ vi.mock("@lib/identity", () => ({ import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity"; const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished); -import { setMuted as mockSetMuted, setDeafened as mockSetDeafened } from "@lib/livekitSession"; +import { + setMuted as mockSetMuted, + setDeafened as mockSetDeafened, + leaveVoice as mockLeaveVoice, + disableCamera as mockDisableCamera, +} from "@lib/livekitSession"; // Suppress console output vi.spyOn(console, "info").mockImplementation(() => {}); @@ -73,6 +81,7 @@ vi.spyOn(console, "error").mockImplementation(() => {}); function createMockWs() { const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); const sendFailureListeners = new Set<(id: string, code: string) => void>(); + const stateListeners = new Set<(state: ConnectionState) => void>(); const ws: WsClient = { connect: vi.fn(), @@ -87,7 +96,10 @@ function createMockWs() { listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>); }; }, - onStateChange: vi.fn(() => () => {}), + onStateChange(listener: (state: ConnectionState) => void): () => void { + stateListeners.add(listener); + return () => stateListeners.delete(listener); + }, onSendFailure(listener: (id: string, code: string) => void): () => void { sendFailureListeners.add(listener); return () => sendFailureListeners.delete(listener); @@ -116,7 +128,13 @@ function createMockWs() { } } - return { ws, dispatch, dispatchSendFailure, listeners }; + function dispatchState(state: ConnectionState): void { + for (const listener of stateListeners) { + listener(state); + } + } + + return { ws, dispatch, dispatchSendFailure, dispatchState, listeners }; } describe("WS Dispatcher", () => { @@ -190,6 +208,39 @@ describe("WS Dispatcher", () => { expect(state.serverName).toBe("TestServer"); }); + it("re-sends channel_focus for the active channel on auth_ok", () => { + // The resume path can land with no ChannelTopic subscription (server + // restart / proxy close observed before the client's reconnect) — a + // channel already active on the client must be re-focused so the + // channel message stream doesn't silently die. + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 42 })); + + mock.dispatch("auth_ok", { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "TestServer", + motd: "Welcome!", + }); + + expect(mock.ws.send).toHaveBeenCalledWith({ + type: "channel_focus", + payload: { channel_id: 42 }, + }); + }); + + it("sends no channel_focus on auth_ok when no channel is active", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: null })); + + mock.dispatch("auth_ok", { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "TestServer", + motd: "Welcome!", + }); + + expect(mock.ws.send).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "channel_focus" }), + ); + }); + it("wires auth_error to clear auth", () => { mock.dispatch("auth_error", { message: "Invalid token" }); expect(authStore.getState().isAuthenticated).toBe(false); @@ -384,6 +435,97 @@ describe("WS Dispatcher", () => { expect(member?.avatar).toBe("/api/v1/files/abc"); }); + describe("presence and user_update sync dmStore", () => { + const dmChannel = { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "old.png", status: "online" as const }, + participants: [{ id: 10, username: "bob", avatar: "old.png", status: "online" as const }], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }; + + beforeEach(() => { + dmStore.setState(() => ({ + channels: [{ ...dmChannel, participants: [...dmChannel.participants] }], + })); + }); + + it("updates the DM partner's status on presence, in recipient and participants", () => { + mock.dispatch("presence", { user_id: 10, status: "dnd" }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.recipient.status).toBe("dnd"); + expect(dm?.participants[0]?.status).toBe("dnd"); + }); + + it("leaves an unrelated DM partner's status alone", () => { + mock.dispatch("presence", { user_id: 999, status: "dnd" }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.recipient.status).toBe("online"); + }); + + it("updates the DM partner's username/avatar/displayName on user_update", () => { + mock.dispatch("user_update", { + user_id: 10, + username: "bobby", + avatar: "new.png", + display_name: "Bobby", + about: "", + }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.recipient.username).toBe("bobby"); + expect(dm?.recipient.avatar).toBe("new.png"); + expect(dm?.recipient.displayName).toBe("Bobby"); + expect(dm?.participants[0]?.username).toBe("bobby"); + }); + + it("clears the DM partner's nickname when user_update reports it cleared", () => { + dmStore.setState((prev) => ({ + channels: prev.channels.map((c) => ({ + ...c, + recipient: { ...c.recipient, displayName: "Bobby" }, + participants: c.participants.map((p) => ({ ...p, displayName: "Bobby" })), + })), + })); + + mock.dispatch("user_update", { + user_id: 10, + username: "bob", + avatar: "old.png", + display_name: null, + }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.recipient.displayName).toBe(""); + }); + + // An older or partial server omits display_name entirely; that means + // "unchanged", not "cleared" — membersStore already guards it this way. + it("leaves the DM partner's nickname alone when user_update omits display_name", () => { + dmStore.setState((prev) => ({ + channels: prev.channels.map((c) => ({ + ...c, + recipient: { ...c.recipient, displayName: "Bobby" }, + participants: c.participants.map((p) => ({ ...p, displayName: "Bobby" })), + })), + })); + + mock.dispatch("user_update", { user_id: 10, username: "bobby", avatar: "new.png" }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.recipient.username).toBe("bobby"); + expect(dm?.recipient.displayName).toBe("Bobby"); + expect(dm?.participants[0]?.displayName).toBe("Bobby"); + }); + }); + it("wires typing to members store", () => { mock.dispatch("typing", { channel_id: 1, user_id: 42, username: "bob" }); const typing = membersStore.getState().typingUsers.get(1); @@ -564,7 +706,34 @@ describe("WS Dispatcher", () => { expect(channelsStore.getState().activeChannelId).toBe(7); }); - it("ready does NOT change active channel when one is already set", () => { + it("ready does NOT change active channel when it is still present in the payload", () => { + // Regression guard for the auto-select branch: an already-active channel + // that is STILL in the new snapshot must not be reassigned to the first + // text channel (99 sorts after 1, so a naive "pick first" would move it). + channelsStore.setState((prev) => ({ + ...prev, + activeChannelId: 99, + })); + + mock.dispatch("ready", { + channels: [ + { id: 1, name: "general", type: "text", category: null, position: 0 }, + { id: 99, name: "kept", type: "text", category: null, position: 1 }, + ], + members: [], + voice_states: [], + roles: [], + }); + + expect(channelsStore.getState().activeChannelId).toBe(99); + }); + + it("ready clears the active channel when it is no longer present in the payload", () => { + // Was locked as "does NOT change active channel when one is already + // set" — but 99 was never actually IN that payload, so this was really + // pinning the bug (BUG report #2): a channel deleted/closed while this + // client was offline stayed "active" forever, leaving its message list + // and composer mounted against a channel the server no longer knows. channelsStore.setState((prev) => ({ ...prev, activeChannelId: 99, @@ -577,7 +746,30 @@ describe("WS Dispatcher", () => { roles: [], }); - expect(channelsStore.getState().activeChannelId).toBe(99); + expect(channelsStore.getState().activeChannelId).toBeNull(); + }); + + it("ready keeps the active DM channel when it is still present in dm_channels", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + + mock.dispatch("ready", { + channels: [], + members: [], + voice_states: [], + roles: [], + dm_channels: [ + { + channel_id: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + last_message_id: null, + last_message: "", + last_message_at: "", + unread_count: 0, + }, + ], + }); + + expect(channelsStore.getState().activeChannelId).toBe(50); }); it("ready with no text channels does not set active", () => { @@ -591,7 +783,7 @@ describe("WS Dispatcher", () => { expect(channelsStore.getState().activeChannelId).toBeNull(); }); - it("ready with no DM channels in payload skips setDmChannels", () => { + it("ready with no DM channels in payload leaves dmStore empty", () => { mock.dispatch("ready", { channels: [], members: [], @@ -602,6 +794,178 @@ describe("WS Dispatcher", () => { expect(dmStore.getState().channels).toHaveLength(0); }); + it("ready with an empty dm_channels array clears stale DM rows", () => { + // The server always sends dm_channels; [] is an authoritative "no open + // DMs" (all closed on another device), not "nothing to say". + dmStore.setState(() => ({ + channels: [ + { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + ], + })); + + mock.dispatch("ready", { + channels: [], + members: [], + voice_states: [], + roles: [], + dm_channels: [], + }); + + expect(dmStore.getState().channels).toHaveLength(0); + }); + + describe("ready re-marks the active channel read", () => { + afterEach(() => { + setMarkReadSender(null); + }); + + it("clears the resurrected badge and advances the server read state for the focused channel", () => { + // read_states go stale while a channel stays focused (channel_focus is + // sent once per mount), so a full-ready resync restates non-zero counts + // for the channel the user is currently reading. + const sender = vi.fn(); + setMarkReadSender(sender); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + + mock.dispatch("ready", { + channels: [ + { + id: 1, + name: "general", + type: "text", + category: null, + position: 0, + unread_count: 4, + mention_count: 2, + }, + ], + members: [], + voice_states: [], + roles: [], + dm_channels: [], + }); + + const ch = channelsStore.getState().channels.get(1); + expect(ch?.unreadCount).toBe(0); + expect(ch?.mentionCount).toBe(0); + expect(sender).toHaveBeenCalledWith(1); + }); + + it("clears the badge for the actively viewed DM too", () => { + const sender = vi.fn(); + setMarkReadSender(sender); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + + mock.dispatch("ready", { + channels: [], + members: [], + voice_states: [], + roles: [], + dm_channels: [ + { + channel_id: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + last_message_id: 5, + last_message: "hello", + last_message_at: "2026-03-15T10:00:00Z", + unread_count: 3, + mention_count: 1, + }, + ], + }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.unreadCount).toBe(0); + expect(dm?.mentionCount).toBe(0); + expect(sender).toHaveBeenCalledWith(50); + }); + + it("does not send mark_read when no channel was active before the ready", () => { + const sender = vi.fn(); + setMarkReadSender(sender); + + mock.dispatch("ready", { + channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }], + members: [], + voice_states: [], + roles: [], + dm_channels: [], + }); + + // Auto-select ran, but a first connect is not a resync — the payload's + // counts are fresh and the user was not yet reading anything. + expect(sender).not.toHaveBeenCalled(); + }); + }); + + it("fails every pending optimistic send when the connection drops", () => { + addOptimisticMessage({ + correlationId: "corr-drop", + channelId: 1, + user: { id: 1, username: "alex", avatar: null }, + content: "in flight", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + // Reaching connected must not fail anything… + mock.dispatchState("connected"); + expect(messagesStore.getState().messagesByChannel.get(1)![0]!.status).toBe("pending"); + + // …but the connection dropping can never deliver chat_send_ok for the + // pending frame, so the row must fail with retry instead of spinning. + mock.dispatchState("reconnecting"); + + const msg = messagesStore.getState().messagesByChannel.get(1)![0]!; + expect(msg.status).toBe("failed"); + expect(msg.errorCode).toBe("OFFLINE"); + expect(messagesStore.getState().pendingSends.size).toBe(0); + }); + + it("rolls back every pending optimistic reaction toggle when the connection drops", () => { + mock.dispatch("chat_message", { + id: 900, + channel_id: 1, + user: { id: 1, username: "alex", avatar: null }, + content: "react to me", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + addOptimisticReaction("corr-react-drop", { + channelId: 1, + messageId: 900, + emoji: "👍", + action: "add", + }); + + const before = getChannelMessages(1)[0]!.reactions.find((r) => r.emoji === "👍"); + expect(before?.count).toBe(1); + expect(before?.me).toBe(true); + expect(messagesStore.getState().pendingReactions?.has("corr-react-drop")).toBe(true); + + // Same reasoning as pendingSends above: the frame is gone with the dying + // socket, so the toggle must roll back instead of leaving a permanently + // wrong pill and a stale pendingReactions entry. + mock.dispatchState("reconnecting"); + + const after = getChannelMessages(1)[0]!.reactions.find((r) => r.emoji === "👍"); + expect(after).toBeUndefined(); + expect(messagesStore.getState().pendingReactions?.has("corr-react-drop")).toBe(false); + }); + it("wires chat_edited to messages store", () => { // First add a message mock.dispatch("chat_message", { @@ -787,6 +1151,7 @@ describe("WS Dispatcher", () => { }); it("wires channel_delete and redirects to first text channel when active is deleted", () => { + mockShowToast.mockClear(); channelsStore.setState((prev) => { const ch = new Map(prev.channels); ch.set(10, { @@ -828,6 +1193,54 @@ describe("WS Dispatcher", () => { expect(channelsStore.getState().channels.has(10)).toBe(false); expect(channelsStore.getState().activeChannelId).toBe(20); + // The redirect must say why it happened (ux/channels-members-dms §1.2). + expect(mockShowToast).toHaveBeenCalledWith("This channel was deleted", "info"); + }); + + it("wires channel_delete without a toast when a non-active channel is deleted", () => { + mockShowToast.mockClear(); + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(10, { + id: 10, + name: "active-ch", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + ch.set(20, { + id: 20, + name: "background", + type: "text" as const, + category: null, + position: 1, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: ch, activeChannelId: 10 }; + }); + + mock.dispatch("channel_delete", { id: 20 }); + + expect(channelsStore.getState().channels.has(20)).toBe(false); + expect(channelsStore.getState().activeChannelId).toBe(10); + expect(mockShowToast).not.toHaveBeenCalled(); }); it("wires channel_delete sets active to null when no text channels remain", () => { @@ -874,6 +1287,40 @@ describe("WS Dispatcher", () => { expect(membersStore.getState().members.get(42)?.role).toBe("admin"); }); + it("syncs authStore.user.role when the member_update is about the signed-in user", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 42, username: "alice", avatar: null, role: "member" }, + })); + membersStore.setState((prev) => { + const m = new Map(prev.members); + m.set(42, { + id: 42, + username: "alice", + avatar: null, + role: "member", + status: "online" as const, + }); + return { ...prev, members: m }; + }); + + mock.dispatch("member_update", { user_id: 42, role: "admin" }); + + expect(membersStore.getState().members.get(42)?.role).toBe("admin"); + expect(authStore.getState().user?.role).toBe("admin"); + }); + + it("leaves authStore.user.role untouched for a member_update about someone else", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + + mock.dispatch("member_update", { user_id: 42, role: "admin" }); + + expect(authStore.getState().user?.role).toBe("member"); + }); + it("wires roles_update to replace the role list", () => { channelsStore.setState((prev) => ({ ...prev, @@ -1037,6 +1484,54 @@ describe("WS Dispatcher", () => { expect(voiceStore.getState().currentChannelId).toBe(3); }); + // A server-initiated eviction (CONNECT_VOICE revocation sweep, channel + // delete) has no companion teardown message — voice_leave for the local + // user IS the signal that must also tear down the LiveKit session, or mic + // publish + E2EE key material stay live while the UI shows not-in-voice. + it("tears down the LiveKit session on a self voice_leave for the current channel", async () => { + vi.mocked(mockLeaveVoice).mockClear(); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 3, + })); + + mock.dispatch("voice_leave", { + channel_id: 3, + user_id: 5, + }); + await vi.runAllTimersAsync(); + + expect(mockLeaveVoice).toHaveBeenCalledWith(false); + }); + + // A stale voice_leave for a channel we've already left (and rejoined + // elsewhere) must not kill the newer join's live session. + it("does not tear down the session for a stale voice_leave from a channel already left", async () => { + vi.mocked(mockLeaveVoice).mockClear(); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + // Currently in channel 9 (a newer join) — the incoming voice_leave is for + // the old channel 3. + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 9, + })); + + mock.dispatch("voice_leave", { + channel_id: 3, + user_id: 5, + }); + await vi.runAllTimersAsync(); + + expect(mockLeaveVoice).not.toHaveBeenCalled(); + }); + it("mirrors a moderator mute/deafen into the local flags and honors it", async () => { authStore.setState((prev) => ({ ...prev, @@ -1386,6 +1881,46 @@ describe("WS Dispatcher", () => { expect(blocksStore.getState().blockedByThem.size).toBe(0); }); + it("does not gate blockedByThem on a FORBIDDEN send in a group DM", () => { + // Group DMs are exempt from block checks server-side (a group FORBIDDEN + // means something else, e.g. stale membership) — recipient is just + // participants[0] for a group, so flagging it here would incorrectly + // gate an unrelated 1:1 DM with that same person. + dmStore.setState(() => ({ + channels: [ + { + channelId: 8, + recipient: { id: 5, username: "alice", avatar: "", status: "online" }, + participants: [ + { id: 5, username: "alice", avatar: "", status: "online" }, + { id: 6, username: "bob", avatar: "", status: "online" }, + ], + name: "Crew", + isGroup: true, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + ], + })); + addOptimisticMessage({ + correlationId: "corr-group", + channelId: 8, + user: { id: 1, username: "alex", avatar: null }, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + mock.dispatch("error", { code: "FORBIDDEN", message: "not a participant" }, "corr-group"); + + expect(blocksStore.getState().blockedByThem.has(5)).toBe(false); + // Still marks the row failed (existing behaviour preserved). + expect(getChannelMessages(8)[0]!.status).toBe("failed"); + }); + it("on ready publishes the client's identity key when the server copy is stale", async () => { cleanup(); // tear down the no-api dispatcher wired in beforeEach mockEnsurePublished.mockClear(); @@ -1705,6 +2240,54 @@ describe("WS Dispatcher", () => { (mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(false); }); + + it("increments the DM mention badge for an incoming @mention", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + + mock.dispatch("chat_message", { + id: 504, + channel_id: 50, + user: { id: 10, username: "bob", avatar: "" }, + content: "hey @me", + mentions: [5], + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + // The badge must fire live: dmStore's mentionCount is what DmSidebar + // renders (mute-immune), and channelsStore's incrementMention no-ops + // for DM ids. Without this, the badge appears only after a reconnect. + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.mentionCount).toBe(1); + expect(dm?.unreadCount).toBe(1); + }); + + it("does not badge a DM mention in the focused DM", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + + mock.dispatch("chat_message", { + id: 505, + channel_id: 50, + user: { id: 10, username: "bob", avatar: "" }, + content: "hey @me", + mentions: [5], + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 50); + expect(dm?.mentionCount).toBe(0); + }); }); // ── DM events ───────────────────────────────────────── @@ -1794,6 +2377,110 @@ describe("WS Dispatcher", () => { mock.dispatch("dm_channel_close", { channel_id: 50 }); expect(dmStore.getState().channels).toHaveLength(0); }); + + it("falls back to another open DM when the closed DM was active", () => { + dmStore.setState(() => ({ + channels: [ + { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + { + channelId: 60, + recipient: { id: 11, username: "carl", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + ], + })); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + + mock.dispatch("dm_channel_close", { channel_id: 50 }); + + expect(dmStore.getState().channels.map((c) => c.channelId)).toEqual([60]); + expect(channelsStore.getState().activeChannelId).toBe(60); + }); + + it("falls back to the first text channel when the closed DM was active and no DMs remain", () => { + dmStore.setState(() => ({ + channels: [ + { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + ], + })); + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, { + id: 1, + name: "general", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: ch, activeChannelId: 50 }; + }); + + mock.dispatch("dm_channel_close", { channel_id: 50 }); + + expect(channelsStore.getState().activeChannelId).toBe(1); + }); + + it("does not change the active channel when the closed DM was not active", () => { + dmStore.setState(() => ({ + channels: [ + { + channelId: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }, + ], + })); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + + mock.dispatch("dm_channel_close", { channel_id: 50 }); + + expect(channelsStore.getState().activeChannelId).toBe(1); + }); }); it("auth_ok with null token uses empty string fallback", () => { @@ -2003,6 +2690,17 @@ describe("WS Dispatcher", () => { ); }); + // max_video has no SFU-level enforcement — the server's VIDEO_LIMIT refusal + // only rejects the DB write. Without a client-side rollback the refused + // camera track keeps publishing (and streaming to everyone) while + // voice_state says camera=false, so VIDEO_LIMIT is otherwise cosmetic. + it("rolls back the local camera publish on VIDEO_LIMIT", async () => { + mock.dispatch("error", { code: "VIDEO_LIMIT", message: "" }); + await vi.runAllTimersAsync(); + + expect(mockDisableCamera).toHaveBeenCalled(); + }); + // A capacity refusal is about the voice channel, not about the composer, // so it must not also land in the login screen's transient-error slot. it("does not set the transient error", () => { diff --git a/Client/tauri-client/tests/unit/dm-profile-sidebar.test.ts b/Client/tauri-client/tests/unit/dm-profile-sidebar.test.ts index 50bfbcd2..5022c588 100644 --- a/Client/tauri-client/tests/unit/dm-profile-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/dm-profile-sidebar.test.ts @@ -180,4 +180,44 @@ describe("DmProfileSidebar", () => { sidebar2.destroy?.(); localStorage.removeItem("owncord:dm-note:99"); }); + + it("scopes the note to the server host when one is supplied", () => { + // User ids are per-server, so an unscoped key means a note about user 5 + // on server A is shown for, and overwritten by, the unrelated user 5 on + // server B in the multi-profile client. + const user = makeUser({ id: 5 }); + const sidebarA = createDmProfileSidebar(makeOptions({ user, host: "a.example.com" })); + sidebarA.mount(container); + const noteElA = container.querySelector('[data-testid="dps-note"]') as HTMLTextAreaElement; + noteElA.value = "Note about server A's user 5"; + noteElA.dispatchEvent(new Event("input")); + sidebarA.destroy?.(); + + expect(localStorage.getItem("owncord:dm-note:a.example.com:5")).toBe( + "Note about server A's user 5", + ); + + // The unrelated user 5 on a different server sees no note. + const sidebarB = createDmProfileSidebar(makeOptions({ user, host: "b.example.com" })); + sidebarB.mount(container); + const noteElB = container.querySelector('[data-testid="dps-note"]') as HTMLTextAreaElement; + expect(noteElB.value).toBe(""); + sidebarB.destroy?.(); + + localStorage.removeItem("owncord:dm-note:a.example.com:5"); + }); + + it("falls back to the legacy unscoped key to migrate a note saved before host-scoping", () => { + const user = makeUser({ id: 42 }); + localStorage.setItem("owncord:dm-note:42", "Pre-existing note"); + + const sidebar = createDmProfileSidebar(makeOptions({ user, host: "a.example.com" })); + sidebar.mount(container); + const noteEl = container.querySelector('[data-testid="dps-note"]') as HTMLTextAreaElement; + + expect(noteEl.value).toBe("Pre-existing note"); + + sidebar.destroy?.(); + localStorage.removeItem("owncord:dm-note:42"); + }); }); diff --git a/Client/tauri-client/tests/unit/dm-store.test.ts b/Client/tauri-client/tests/unit/dm-store.test.ts index 735362cf..b629fa30 100644 --- a/Client/tauri-client/tests/unit/dm-store.test.ts +++ b/Client/tauri-client/tests/unit/dm-store.test.ts @@ -1,14 +1,38 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { dmStore, setDmChannels, addDmChannel, removeDmChannel, + closeDmLocally, updateDmLastMessage, updateDmLastMessagePreview, clearDmUnread, + updateDmParticipant, } from "../../src/stores/dm.store"; import type { DmChannel } from "../../src/stores/dm.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import type { Channel } from "../../src/stores/channels.store"; + +function makeMirrorChannel(overrides: Partial<Channel> = {}): Channel { + return { + id: 5, + name: "bob", + type: "dm", + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + slowMode: 0, + topic: "", + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + ...overrides, + }; +} function makeDm(overrides: Partial<DmChannel> = {}): DmChannel { return { @@ -113,6 +137,168 @@ describe("dmStore", () => { }); }); + // ── closeDmLocally ───────────────────────────────────── + + describe("closeDmLocally", () => { + beforeEach(() => { + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + }); + + it("removes the channel and does not run the fallback when it was not active", () => { + setDmChannels([makeDm({ channelId: 5 }), makeDm({ channelId: 6 })]); + const fallback = vi.fn(); + + closeDmLocally(5, fallback); + + expect(dmStore.getState().channels.map((c) => c.channelId)).toEqual([6]); + expect(fallback).not.toHaveBeenCalled(); + }); + + it("removes the channel and runs the fallback when it was the active channel", () => { + setDmChannels([makeDm({ channelId: 5 })]); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 5 })); + const fallback = vi.fn(); + + closeDmLocally(5, fallback); + + expect(dmStore.getState().channels).toHaveLength(0); + expect(fallback).toHaveBeenCalledOnce(); + }); + + // Regression: addDmToChannelsStore synthesizes a `type: "dm"` mirror row + // into channelsStore on selection, which setChannels re-carries across + // every `ready`. closeDmLocally must remove that mirror too, or its + // unread count survives the close and keeps "Mark All as Read" lit for a + // DM that is no longer open anywhere. + it("also removes the channelsStore mirror row for the closed DM", () => { + setDmChannels([makeDm({ channelId: 5 })]); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(5, makeMirrorChannel({ id: 5, unreadCount: 3 })); + return { ...prev, channels: next }; + }); + + closeDmLocally(5, vi.fn()); + + expect(channelsStore.getState().channels.has(5)).toBe(false); + }); + + it("clears activeChannelId on the channelsStore mirror when it was active there too", () => { + setDmChannels([makeDm({ channelId: 5 })]); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(5, makeMirrorChannel({ id: 5 })); + return { ...prev, channels: next, activeChannelId: 5 }; + }); + const fallback = vi.fn(); + + closeDmLocally(5, fallback); + + expect(channelsStore.getState().activeChannelId).toBeNull(); + expect(fallback).toHaveBeenCalledOnce(); + }); + + it("does not touch an unrelated channelsStore mirror row", () => { + setDmChannels([makeDm({ channelId: 5 }), makeDm({ channelId: 6 })]); + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(5, makeMirrorChannel({ id: 5 })); + next.set(6, makeMirrorChannel({ id: 6 })); + return { ...prev, channels: next }; + }); + + closeDmLocally(5, vi.fn()); + + expect(channelsStore.getState().channels.has(6)).toBe(true); + }); + }); + + // ── updateDmParticipant ───────────────────────────────── + + describe("updateDmParticipant", () => { + it("patches the recipient's status across matching DM channels", () => { + setDmChannels([ + makeDm({ + channelId: 5, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [{ id: 10, username: "bob", avatar: "", status: "online" }], + }), + ]); + + updateDmParticipant(10, { status: "dnd" }); + + const ch = dmStore.getState().channels[0]!; + expect(ch.recipient.status).toBe("dnd"); + expect(ch.participants[0]!.status).toBe("dnd"); + }); + + it("patches username/avatar/displayName on a profile change", () => { + setDmChannels([ + makeDm({ + channelId: 5, + recipient: { id: 10, username: "bob", avatar: "old.png", status: "online" }, + participants: [{ id: 10, username: "bob", avatar: "old.png", status: "online" }], + }), + ]); + + updateDmParticipant(10, { username: "bobby", avatar: "new.png", displayName: "Bobby" }); + + const ch = dmStore.getState().channels[0]!; + expect(ch.recipient.username).toBe("bobby"); + expect(ch.recipient.avatar).toBe("new.png"); + expect(ch.recipient.displayName).toBe("Bobby"); + }); + + it("updates a non-recipient participant of a group DM", () => { + setDmChannels([ + makeDm({ + channelId: 5, + isGroup: true, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [ + { id: 10, username: "bob", avatar: "", status: "online" }, + { id: 11, username: "carol", avatar: "", status: "online" }, + ], + }), + ]); + + updateDmParticipant(11, { status: "idle" }); + + const ch = dmStore.getState().channels[0]!; + expect(ch.recipient.status).toBe("online"); + expect(ch.participants.find((p) => p.id === 11)?.status).toBe("idle"); + }); + + it("is a no-op when the user id matches no participant anywhere", () => { + setDmChannels([makeDm({ channelId: 5 })]); + const before = dmStore.getState(); + + updateDmParticipant(999, { status: "dnd" }); + + expect(dmStore.getState()).toBe(before); + }); + + it("does not modify channels the user is not part of", () => { + setDmChannels([ + makeDm({ + channelId: 5, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [{ id: 10, username: "bob", avatar: "", status: "online" }], + }), + makeDm({ + channelId: 6, + recipient: { id: 20, username: "carol", avatar: "", status: "online" }, + participants: [{ id: 20, username: "carol", avatar: "", status: "online" }], + }), + ]); + + updateDmParticipant(10, { status: "dnd" }); + + const other = dmStore.getState().channels.find((c) => c.channelId === 6)!; + expect(other.recipient.status).toBe("online"); + }); + }); + // ── updateDmLastMessage ──────────────────────────────── describe("updateDmLastMessage", () => { diff --git a/Client/tauri-client/tests/unit/drag-reorder.test.ts b/Client/tauri-client/tests/unit/drag-reorder.test.ts index 1779f0cb..3fff4a83 100644 --- a/Client/tauri-client/tests/unit/drag-reorder.test.ts +++ b/Client/tauri-client/tests/unit/drag-reorder.test.ts @@ -13,13 +13,13 @@ 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 { channelsStore, setRoles } from "@stores/channels.store"; import type { Channel } from "@stores/channels.store"; import type { UserWithRole } from "@lib/types"; +import { Permission } from "@lib/types"; // ── helpers ──────────────────────────────────────────────────────────────── @@ -64,6 +64,10 @@ interface Rig { abort: AbortController; } +/** Every rig's owner controller, aborted in afterEach so the shared document + * listeners are fully torn down between tests. */ +const rigAborts: AbortController[] = []; + /** Builds a container with one 20px-tall row per channel, stacked vertically. */ function buildRig(channels: Channel[]): Rig { const container = document.createElement("div"); @@ -72,6 +76,7 @@ function buildRig(channels: Channel[]): Rig { const items = new Map<number, HTMLElement>(); const onReorder = vi.fn(); const abort = new AbortController(); + rigAborts.push(abort); channels.forEach((ch, idx) => { const el = document.createElement("div"); @@ -102,8 +107,12 @@ function yInRow(idx: number, half: "top" | "bottom"): number { return idx * 20 + (half === "top" ? 4 : 16); } +/** A real drag holds the left button down for its whole duration, so `buttons` + * (the held-button bitmask) is set alongside `button` (which button changed) + * on every synthetic event — mousemove gates on `buttons` to detect a stale + * latch left by an off-row release. */ function mouse(type: string, clientX: number, clientY: number): MouseEvent { - return new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true }); + return new MouseEvent(type, { clientX, clientY, button: 0, buttons: 1, bubbles: true }); } /** Drives a full drag of `fromId` onto the given half of row `toIdx`. */ @@ -132,16 +141,17 @@ beforeEach(() => { }); 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(); + // End any drag still in flight so it cannot leak into the next test. + // Re-arm the global handlers under a throwaway owner first: a test that + // aborted every owner tore them down, and the mouseup below needs a live + // listener to do the clearing. + const flush = new AbortController(); + ensureGlobalDragListeners(flush.signal); 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(); + flush.abort(); + // Abort every rig owner so the shared document listeners are torn down. + for (const ac of rigAborts) ac.abort(); + rigAborts.length = 0; document.body.innerHTML = ""; document.body.className = ""; }); @@ -165,6 +175,25 @@ describe("attachDragHandlers permission gate", () => { expect(el?.classList.contains("channel-draggable")).toBe(draggable); }); + // Gated on the MANAGE_CHANNELS bit, not the literal role name — the same + // derivation as Edit/Delete (context-menu.ts) and the category "+" + // (ChannelSidebar.ts), so the three cannot drift apart on who may reorder. + it("makes a custom role holding MANAGE_CHANNELS draggable, even though its name is neither owner nor admin", () => { + setRoles([{ id: 9, name: "Curator", color: null, permissions: Permission.MANAGE_CHANNELS }]); + signIn("Curator"); + const rig = buildRig([makeCh(1, 0)]); + + expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(true); + }); + + it("does not make a role literally named 'admin' draggable when it lacks MANAGE_CHANNELS", () => { + setRoles([{ id: 9, name: "admin", color: null, permissions: 0 }]); + signIn("admin"); + const rig = buildRig([makeCh(1, 0)]); + + expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false); + }); + it("does nothing when no user is signed in", () => { const rig = buildRig([makeCh(1, 0)]); @@ -241,6 +270,37 @@ describe("drag activation threshold", () => { expect(el?.classList.contains("dragging")).toBe(false); }); + + it("a stale pending-drag latch left by an off-row release does not resume on a later buttonless hover", () => { + // Press near row 1, drift under the 5px threshold, and release over row 2: + // no drag ran, and because mousedown/mouseup targeted different elements, + // row 1's own mouseup listener never fires, so its pendingDrag latch is + // never cleared. + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + const row1 = rig.items.get(1)!; + + row1.dispatchEvent(mouse("mousedown", 0, yInRow(0, "bottom"))); + row1.dispatchEvent(mouse("mousemove", 0, yInRow(0, "bottom") + 1)); // 1px, below threshold + document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top"))); // released over row 2 + + expect(row1.classList.contains("dragging")).toBe(false); + + // Later, the pointer crosses row 1 again with no button held (a plain + // hover). Without the fix this satisfies the 5px threshold against the + // stale startX/startY and silently starts a real drag. + row1.dispatchEvent( + new MouseEvent("mousemove", { + clientX: 0, + clientY: yInRow(0, "top") + 20, + buttons: 0, + bubbles: true, + }), + ); + + expect(row1.classList.contains("dragging")).toBe(false); + expect(document.body.classList.contains("channel-reordering")).toBe(false); + }); }); // ── reorder arithmetic ───────────────────────────────────────────────────── @@ -390,60 +450,59 @@ describe("drop indicator", () => { }); // ── listener lifecycle ───────────────────────────────────────────────────── +// +// Ownership of the shared document listeners is per sidebar AbortSignal, not +// per attached row. This block used to pin the old per-row ref-count's +// asymmetry as a KNOWN BUG ("N rows take N refs, destroy returns 1, the +// listeners live forever"); that bug is fixed, so the block now pins the +// fixed contract: one owner registration no matter how many rows attach, and +// the owner's abort is the release. -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", () => { +describe("global listener ownership", () => { + it("attaching many rows under one owner is a single registration; one abort tears down", () => { signIn("owner"); + // 2 channels, and a re-attach of the same rows (a sidebar re-render) — + // under the old ref-count this took 4 refs a single destroy never repaid. const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + for (const [id, el] of rig.items) { + const ch = rig.channels.find((c) => c.id === id)!; + attachDragHandlers(el, ch, rig.container, rig.channels, rig.abort.signal, rig.onReorder); + } - releaseGlobalDragListeners(); - releaseGlobalDragListeners(); // drops to 0 → AbortController fires + rig.abort.abort(); - drag(rig, 1, 1, "bottom"); + // The single owner is gone → listeners torn down; a drag no longer works. + // (The rig's own element listeners share the aborted signal, so drive the + // document handlers directly to prove they are dead.) + document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top"))); + document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top"))); expect(rig.onReorder).not.toHaveBeenCalled(); }); - it("clears an in-flight drag owned by the released container", () => { + it("keeps the listeners alive while another sidebar still owns them", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const other = new AbortController(); // a second sidebar + ensureGlobalDragListeners(other.signal); + + other.abort(); // the second sidebar goes away + + // The first sidebar still owns the listeners, so its drag must work. + drag(rig, 1, 1, "bottom"); + expect(rig.onReorder).toHaveBeenCalledTimes(1); + }); + + it("re-registration after full teardown works (a new sidebar after all closed)", () => { + signIn("owner"); + const first = buildRig([makeCh(1, 0), makeCh(2, 1)]); + first.abort.abort(); // teardown + + const second = buildRig([makeCh(1, 0), makeCh(2, 1)]); + drag(second, 1, 1, "bottom"); + expect(second.onReorder).toHaveBeenCalledTimes(1); + }); + + it("aborting the owner mid-drag clears the in-flight visual state", () => { signIn("owner"); const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); const source = rig.items.get(1); @@ -452,7 +511,7 @@ describe("global listener ref-counting", () => { source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20)); expect(source?.classList.contains("dragging")).toBe(true); - releaseGlobalDragListeners(rig.container); + rig.abort.abort(); // A sidebar destroyed mid-drag must not leave the row stuck in the // dragging state or the body stuck in reorder mode. @@ -460,26 +519,28 @@ describe("global listener ref-counting", () => { expect(document.body.classList.contains("channel-reordering")).toBe(false); }); - it("leaves a drag owned by a different container alone", () => { + it("leaves a drag owned by a different sidebar alone", () => { signIn("owner"); const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); const source = rig.items.get(1); - const otherContainer = document.createElement("div"); + const other = new AbortController(); + ensureGlobalDragListeners(other.signal); 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); + other.abort(); expect(source?.classList.contains("dragging")).toBe(true); }); - it("release is safe to over-call", () => { + it("an already-aborted owner is refused (no dead registration, abort is safe to repeat)", () => { + const ac = new AbortController(); + ac.abort(); expect(() => { - releaseGlobalDragListeners(); - releaseGlobalDragListeners(); - releaseGlobalDragListeners(); + ensureGlobalDragListeners(ac.signal); + ac.abort(); + ac.abort(); }).not.toThrow(); }); }); diff --git a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts index f11dde7f..c67591dd 100644 --- a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts @@ -551,6 +551,64 @@ describe("EditChannelModal", () => { }); }); + // ─── Dialog accessibility contract (DC-13) ───────────────────────────────── + + describe("dialog accessibility", () => { + it("stamps dialog semantics named by the header title", () => { + const { modal } = makeModal(); + const dialog = container.querySelector(".modal") as HTMLElement; + expect(dialog.getAttribute("role")).toBe("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-labelledby")).toBe("edit-channel-title"); + expect(container.querySelector("#edit-channel-title")?.textContent).toBe("Edit Channel"); + modal.destroy?.(); + }); + + it("labels the icon-only close button", () => { + const { modal } = makeModal(); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(closeBtn.getAttribute("aria-label")).toBe("Close"); + modal.destroy?.(); + }); + + it("Escape calls onClose without saving", () => { + const onSave = vi.fn(async () => {}); + const onClose = vi.fn(); + const { modal } = makeModal({ onSave, onClose }); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("ignores Escape after destroy", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + modal.destroy?.(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("focuses the name input on open and restores focus on destroy", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const { modal } = makeModal(); + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; + expect(document.activeElement).toBe(input); + + modal.destroy?.(); + expect(document.activeElement).toBe(trigger); + }); + }); + // ─── Pure helpers ────────────────────────────────────────────────────────── describe("clampVoiceLimit", () => { diff --git a/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts b/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts index f35e27a1..b5a0e616 100644 --- a/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts +++ b/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts @@ -251,6 +251,82 @@ describe("createEmojiAutocomplete", () => { row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); expect(onSelect).not.toHaveBeenCalled(); }); + + it("stamps a stable id on the listbox and index ids on the rows", () => { + const { ac } = mount(); + ac.setQuery("wa"); + expect(ac.element.id).toBe("emoji-autocomplete"); + const ids = [...ac.element.querySelectorAll(".ma-item")].map((r) => r.id); + expect(ids.length).toBeGreaterThan(1); + ids.forEach((id, i) => expect(id).toBe(`emoji-autocomplete-option-${i}`)); + // A re-render rebuilds the rows, so the ids stay index-based, not stale. + ac.setQuery("flame"); + expect(ac.element.querySelector(".ma-item")?.id).toBe("emoji-autocomplete-option-0"); + ac.destroy(); + }); +}); + +// --------------------------------------------------------------------------- +// Combobox wiring +// --------------------------------------------------------------------------- + +describe("createEmojiAutocomplete combobox wiring", () => { + let ta: HTMLTextAreaElement; + let ac: ReturnType<typeof createEmojiAutocomplete>; + + beforeEach(() => { + ta = document.createElement("textarea"); + document.body.appendChild(ta); + ac = createEmojiAutocomplete({ onSelect: vi.fn(), onClose: vi.fn(), comboboxInput: ta }); + document.body.appendChild(ac.element); + }); + + afterEach(() => { + ac.destroy(); + ta.remove(); + }); + + function key(k: string): KeyboardEvent { + return new KeyboardEvent("keydown", { key: k, cancelable: true }); + } + + it("stamps combobox semantics on the input, without an active row yet", () => { + expect(ta.getAttribute("role")).toBe("combobox"); + expect(ta.getAttribute("aria-autocomplete")).toBe("list"); + expect(ta.getAttribute("aria-expanded")).toBe("true"); + expect(ta.getAttribute("aria-controls")).toBe("emoji-autocomplete"); + // Emoji do not prime on create, so no row exists to point at yet. + expect(ta.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("aims aria-activedescendant at the active row and follows the arrows", () => { + ac.setQuery("wa"); + expect(ta.getAttribute("aria-activedescendant")).toBe("emoji-autocomplete-option-0"); + ac.handleKeydown(key("ArrowDown")); + expect(ta.getAttribute("aria-activedescendant")).toBe("emoji-autocomplete-option-1"); + ac.handleKeydown(key("ArrowUp")); + expect(ta.getAttribute("aria-activedescendant")).toBe("emoji-autocomplete-option-0"); + }); + + it("clears aria-activedescendant when nothing matches", () => { + ac.setQuery("wa"); + ac.setQuery("zzzzqqq"); + expect(ta.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("removes every combobox attribute on destroy", () => { + ac.setQuery("wa"); + ac.destroy(); + for (const attr of [ + "role", + "aria-autocomplete", + "aria-expanded", + "aria-controls", + "aria-activedescendant", + ]) { + expect(ta.hasAttribute(attr)).toBe(false); + } + }); }); // --------------------------------------------------------------------------- @@ -383,4 +459,39 @@ describe("composer :shortcode integration", () => { ).not.toBeNull(); expect(popupEl()).toBeNull(); }); + + it("marks the textarea as a combobox while open and clears it on close", () => { + type(":wave"); + const ta = textarea(); + expect(ta.getAttribute("role")).toBe("combobox"); + expect(ta.getAttribute("aria-expanded")).toBe("true"); + expect(ta.getAttribute("aria-controls")).toBe("emoji-autocomplete"); + expect(ta.getAttribute("aria-activedescendant")).toBe("emoji-autocomplete-option-0"); + press("Escape"); + expect(ta.hasAttribute("role")).toBe(false); + expect(ta.hasAttribute("aria-expanded")).toBe(false); + expect(ta.hasAttribute("aria-controls")).toBe(false); + expect(ta.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("hands the combobox state over when the @-mention popup takes the caret", () => { + membersStore.setState(() => ({ + members: new Map([ + [ + 1, + { id: 1, username: "wave_guy", avatar: null, role: "member", status: "online" as const }, + ], + ]), + typingUsers: new Map(), + })); + type(":wave"); + expect(textarea().getAttribute("aria-controls")).toBe("emoji-autocomplete"); + // The mention popup opens before the emoji popup is torn down, so the + // teardown must not wipe the state the mention popup just stamped. + type("@wa"); + const ta = textarea(); + expect(ta.getAttribute("role")).toBe("combobox"); + expect(ta.getAttribute("aria-controls")).toBe("mention-autocomplete"); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-0"); + }); }); diff --git a/Client/tauri-client/tests/unit/emoji-picker.test.ts b/Client/tauri-client/tests/unit/emoji-picker.test.ts index a57060a2..2681a865 100644 --- a/Client/tauri-client/tests/unit/emoji-picker.test.ts +++ b/Client/tauri-client/tests/unit/emoji-picker.test.ts @@ -203,4 +203,87 @@ describe("EmojiPicker", () => { expect(onSelect).not.toHaveBeenCalled(); }); + + it("marks the scrollable results area as a listbox named Emoji", () => { + const { picker } = makePicker(); + const listbox = picker.element.querySelector("[role='listbox']"); + expect(listbox).not.toBeNull(); + expect(listbox!.getAttribute("aria-label")).toBe("Emoji"); + picker.destroy(); + }); + + it("gives every cell role=option with an aria-label mirroring its title", () => { + const { picker } = makePicker(); + const cells = Array.from(picker.element.querySelectorAll(".ep-emoji")); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell.getAttribute("role")).toBe("option"); + expect(cell.getAttribute("aria-label")).toBe(cell.getAttribute("title")); + } + picker.destroy(); + }); + + it("makes exactly one cell tabbable (roving tabindex)", () => { + const { picker } = makePicker(); + const cells = Array.from(picker.element.querySelectorAll(".ep-emoji")); + const tabbable = cells.filter((c) => c.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + expect(tabbable[0]).toBe(cells[0]); + expect(cells.slice(1).every((c) => c.getAttribute("tabindex") === "-1")).toBe(true); + picker.destroy(); + }); + + it("re-applies the roving tabindex when search replaces the cells", () => { + const { picker } = makePicker(); + const input = picker.element.querySelector(".ep-search") as HTMLInputElement; + input.value = "fire"; + input.dispatchEvent(new Event("input")); + + const cells = Array.from(picker.element.querySelectorAll(".ep-emoji")); + expect(cells.length).toBeGreaterThan(0); + const tabbable = cells.filter((c) => c.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + expect(tabbable[0]).toBe(cells[0]); + picker.destroy(); + }); + + it("ArrowRight moves focus and the tabbable cell to the next emoji", () => { + const { picker } = makePicker(); + const cells = picker.element.querySelectorAll(".ep-emoji") as NodeListOf<HTMLElement>; + expect(cells.length).toBeGreaterThan(1); + + cells[0]!.focus(); + cells[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + + expect(document.activeElement).toBe(cells[1]); + expect(cells[0]!.getAttribute("tabindex")).toBe("-1"); + expect(cells[1]!.getAttribute("tabindex")).toBe("0"); + picker.destroy(); + }); + + it("Enter on a focused cell fires the same onSelect as click", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLElement; + + firstEmoji.focus(); + firstEmoji.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(firstEmoji.getAttribute("title")); + picker.destroy(); + }); + + it("Space on a focused cell fires the same onSelect as click", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLElement; + + firstEmoji.focus(); + firstEmoji.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(firstEmoji.getAttribute("title")); + picker.destroy(); + }); }); diff --git a/Client/tauri-client/tests/unit/file-upload.test.ts b/Client/tauri-client/tests/unit/file-upload.test.ts deleted file mode 100644 index 86769c99..00000000 --- a/Client/tauri-client/tests/unit/file-upload.test.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createFileUpload } from "@components/FileUpload"; -import type { FileUploadOptions, FileUploadComponent } from "@components/FileUpload"; - -describe("FileUpload", () => { - let container: HTMLDivElement; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - container.remove(); - }); - - function makeUpload(overrides?: Partial<FileUploadOptions>): FileUploadComponent { - const options: FileUploadOptions = { - onUpload: overrides?.onUpload ?? vi.fn(async () => {}), - maxSizeMb: overrides?.maxSizeMb, - }; - const upload = createFileUpload(options); - upload.mount(container); - return upload; - } - - it("mounts with file-upload class", () => { - const upload = makeUpload(); - expect(container.querySelector(".file-upload")).not.toBeNull(); - upload.destroy?.(); - }); - - it("renders dropzone (hidden by default)", () => { - const upload = makeUpload(); - const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; - expect(dropzone).not.toBeNull(); - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); - upload.destroy?.(); - }); - - it("renders hidden file input", () => { - const upload = makeUpload(); - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - expect(input).not.toBeNull(); - expect(input.type).toBe("file"); - expect(input.style.display).toBe("none"); - upload.destroy?.(); - }); - - it("preview is hidden by default", () => { - const upload = makeUpload(); - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview).not.toBeNull(); - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); - upload.destroy?.(); - }); - - it("error div is hidden by default", () => { - const upload = makeUpload(); - const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; - expect(errorDiv).not.toBeNull(); - expect(errorDiv.classList.contains("file-upload__error--hidden")).toBe(true); - upload.destroy?.(); - }); - - it("renders drop text in dropzone", () => { - const upload = makeUpload(); - const droptext = container.querySelector(".file-upload__droptext"); - expect(droptext).not.toBeNull(); - expect(droptext!.textContent).toBe("Drop files here"); - upload.destroy?.(); - }); - - it("renders preview sub-elements (thumb, name, size, progress, cancel)", () => { - const upload = makeUpload(); - expect(container.querySelector(".file-upload__thumb")).not.toBeNull(); - expect(container.querySelector(".file-upload__name")).not.toBeNull(); - expect(container.querySelector(".file-upload__size")).not.toBeNull(); - expect(container.querySelector(".file-upload__progress")).not.toBeNull(); - expect(container.querySelector(".file-upload__progress-bar")).not.toBeNull(); - expect(container.querySelector(".file-upload__cancel")).not.toBeNull(); - upload.destroy?.(); - }); - - it("dragenter shows dropzone", () => { - const upload = makeUpload(); - const root = container.querySelector(".file-upload") as HTMLDivElement; - const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; - - root.dispatchEvent(new Event("dragenter", { bubbles: true })); - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(false); - upload.destroy?.(); - }); - - it("dragleave hides dropzone", () => { - const upload = makeUpload(); - const root = container.querySelector(".file-upload") as HTMLDivElement; - const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; - - root.dispatchEvent(new Event("dragenter", { bubbles: true })); - root.dispatchEvent(new Event("dragleave", { bubbles: true })); - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); - upload.destroy?.(); - }); - - it("openPicker triggers file input click", () => { - const upload = makeUpload(); - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - const clickSpy = vi.spyOn(input, "click"); - - upload.openPicker(); - expect(clickSpy).toHaveBeenCalledOnce(); - upload.destroy?.(); - }); - - it("destroy removes DOM", () => { - const upload = makeUpload(); - expect(container.querySelector(".file-upload")).not.toBeNull(); - upload.destroy?.(); - expect(container.querySelector(".file-upload")).toBeNull(); - }); - - // ── File size validation ── - - it("shows error when file exceeds max size limit", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload, maxSizeMb: 5 }); - - const bigFile = new File(["x"], "huge.pdf", { type: "application/pdf" }); - Object.defineProperty(bigFile, "size", { value: 6 * 1024 * 1024 }); // 6 MB > 5 MB limit - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [bigFile], writable: true }); - input.dispatchEvent(new Event("change")); - - // Give async handler a tick - await new Promise((r) => setTimeout(r, 10)); - - // Upload should NOT be called - expect(onUpload).not.toHaveBeenCalled(); - - // Error should be visible with size info - const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; - expect(errorDiv.classList.contains("file-upload__error--hidden")).toBe(false); - expect(errorDiv.textContent).toContain("too large"); - expect(errorDiv.textContent).toContain("5 MB"); - - // Preview should remain hidden - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); - - upload.destroy?.(); - }); - - it("uses default 10 MB limit when maxSizeMb is not specified", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const bigFile = new File(["x"], "huge.pdf", { type: "application/pdf" }); - Object.defineProperty(bigFile, "size", { value: 11 * 1024 * 1024 }); // 11 MB > 10 MB default - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [bigFile], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - expect(onUpload).not.toHaveBeenCalled(); - const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; - expect(errorDiv.classList.contains("file-upload__error--hidden")).toBe(false); - expect(errorDiv.textContent).toContain("10 MB"); - - upload.destroy?.(); - }); - - // ── Successful upload flow ── - - it("shows file name and size in preview during upload", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const file = new File(["hello world test data"], "document.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - // Wait for handleFile to start - await new Promise((r) => setTimeout(r, 10)); - - const nameSpan = container.querySelector(".file-upload__name"); - expect(nameSpan?.textContent).toBe("document.txt"); - - const sizeSpan = container.querySelector(".file-upload__size"); - expect(sizeSpan?.textContent).not.toBe(""); - - // Preview should be visible - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(false); - - upload.destroy?.(); - }); - - it("shows progress and resets preview after successful upload", async () => { - vi.useFakeTimers(); - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - // Wait for async handleFile - await vi.advanceTimersByTimeAsync(10); - - expect(onUpload).toHaveBeenCalledWith(file); - - // Progress bar should be at 100% - const progressBar = container.querySelector(".file-upload__progress-bar") as HTMLDivElement; - expect(progressBar.style.width).toBe("100%"); - - // After 1500ms timeout, preview resets - await vi.advanceTimersByTimeAsync(1500); - - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); - - vi.useRealTimers(); - upload.destroy?.(); - }); - - // ── Upload failure ── - - it("calls onUpload and resets preview when upload fails with Error", async () => { - const onUpload = vi.fn(async () => { - throw new Error("Network timeout"); - }); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await vi.waitFor(() => { - expect(onUpload).toHaveBeenCalledWith(file); - }); - - // After failure, preview is reset (hidden) -- the error text is set - // then resetPreview re-hides it, but the text remains - const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; - expect(errorDiv.textContent).toContain("Network timeout"); - - // Preview should be reset - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); - - upload.destroy?.(); - }); - - it("calls onUpload and shows generic error for non-Error thrown by upload", async () => { - const onUpload = vi.fn(async () => { - throw "string error"; - }); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await vi.waitFor(() => { - expect(onUpload).toHaveBeenCalledWith(file); - }); - - // Error text is set to "Upload failed" for non-Error exceptions - const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; - expect(errorDiv.textContent).toContain("Upload failed"); - - upload.destroy?.(); - }); - - // ── Cancel button aborts upload ── - - it("cancel button aborts in-flight upload and resets preview", async () => { - let resolveUpload: (() => void) | undefined; - const onUpload = vi.fn<any>( - () => - new Promise<void>((resolve) => { - resolveUpload = resolve; - }), - ); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - // Preview should be visible while uploading - const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(false); - - // Click cancel - const cancelBtn = container.querySelector(".file-upload__cancel") as HTMLButtonElement; - cancelBtn.click(); - - // Preview should be reset - expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); - - // Clean up: resolve the pending promise so it doesn't leak - resolveUpload?.(); - - upload.destroy?.(); - }); - - // ── Drop file handling ── - - it("dropping a file triggers upload", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const root = container.querySelector(".file-upload") as HTMLDivElement; - const file = new File(["dropped"], "dropped.pdf", { type: "application/pdf" }); - - const dropEvent = new Event("drop", { bubbles: true }) as Event & { - dataTransfer?: { files: File[] }; - }; - Object.defineProperty(dropEvent, "dataTransfer", { - value: { files: [file] }, - }); - // Need to also define preventDefault - dropEvent.preventDefault = vi.fn(); - - root.dispatchEvent(dropEvent); - - await vi.waitFor(() => { - expect(onUpload).toHaveBeenCalledWith(file); - }); - - // Dropzone should be hidden after drop - const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); - - upload.destroy?.(); - }); - - // ── Image preview shows thumbnail ── - - it("shows image thumbnail for image files", async () => { - // Polyfill URL.createObjectURL/revokeObjectURL for jsdom - const mockUrl = "blob:http://localhost/fake-image-url"; - const origCreate = URL.createObjectURL; - const origRevoke = URL.revokeObjectURL; - URL.createObjectURL = vi.fn(() => mockUrl); - URL.revokeObjectURL = vi.fn(); - - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const imageFile = new File(["img data"], "photo.png", { type: "image/png" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [imageFile], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - const thumb = container.querySelector(".file-upload__thumb") as HTMLImageElement; - expect(thumb.src).toBe(mockUrl); - expect(thumb.style.display).toBe("block"); - - // Restore - URL.createObjectURL = origCreate; - URL.revokeObjectURL = origRevoke; - upload.destroy?.(); - }); - - // ── Multiple drag enter/leave with counter ── - - it("nested dragenter/dragleave keeps dropzone visible until all leave", () => { - const upload = makeUpload(); - const root = container.querySelector(".file-upload") as HTMLDivElement; - const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; - - // Simulate nested dragenter (child element also fires dragenter) - root.dispatchEvent(new Event("dragenter", { bubbles: true })); - root.dispatchEvent(new Event("dragenter", { bubbles: true })); - - // One dragleave -- dropzone should still be visible - root.dispatchEvent(new Event("dragleave", { bubbles: true })); - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(false); - - // Second dragleave -- now it should hide - root.dispatchEvent(new Event("dragleave", { bubbles: true })); - expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); - - upload.destroy?.(); - }); - - // ── Destroy aborts in-flight upload ── - - it("destroy aborts in-flight upload", async () => { - let resolveUpload: (() => void) | undefined; - const onUpload = vi.fn<any>( - () => - new Promise<void>((resolve) => { - resolveUpload = resolve; - }), - ); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - // Destroy should not throw even with upload in flight - upload.destroy?.(); - expect(container.querySelector(".file-upload")).toBeNull(); - - // Clean up - resolveUpload?.(); - }); - - // ── File size formatting ── - - it("displays file size in KB for small files", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const file = new File(["x".repeat(2048)], "small.txt", { type: "text/plain" }); - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - const sizeSpan = container.querySelector(".file-upload__size"); - expect(sizeSpan?.textContent).toContain("KB"); - - upload.destroy?.(); - }); - - it("displays file size in MB for large files", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload, maxSizeMb: 20 }); - - const file = new File(["x"], "big.pdf", { type: "application/pdf" }); - Object.defineProperty(file, "size", { value: 5 * 1024 * 1024 }); // 5 MB - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - const sizeSpan = container.querySelector(".file-upload__size"); - expect(sizeSpan?.textContent).toContain("MB"); - - upload.destroy?.(); - }); - - it("displays file size in B for very small files", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const file = new File(["hi"], "tiny.txt", { type: "text/plain" }); - // File constructor creates a Blob with size = content.length - - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - const sizeSpan = container.querySelector(".file-upload__size"); - // 2 bytes "hi" should display as "2 B" - expect(sizeSpan?.textContent).toContain("B"); - - upload.destroy?.(); - }); - - // ── File input resets after selection ── - - it("resets file input value after change so same file can be re-selected", async () => { - const onUpload = vi.fn(async () => {}); - const upload = makeUpload({ onUpload }); - - const file = new File(["data"], "test.txt", { type: "text/plain" }); - const input = container.querySelector(".file-upload__input") as HTMLInputElement; - - // Set initial value (simulating a previous selection) - input.value = ""; - Object.defineProperty(input, "files", { value: [file], writable: true }); - input.dispatchEvent(new Event("change")); - - await new Promise((r) => setTimeout(r, 10)); - - expect(onUpload).toHaveBeenCalled(); - // Input value should be reset to empty - expect(input.value).toBe(""); - - upload.destroy?.(); - }); -}); diff --git a/Client/tauri-client/tests/unit/gif-picker.test.ts b/Client/tauri-client/tests/unit/gif-picker.test.ts index 4bb202a5..65362f50 100644 --- a/Client/tauri-client/tests/unit/gif-picker.test.ts +++ b/Client/tauri-client/tests/unit/gif-picker.test.ts @@ -655,6 +655,129 @@ describe("GifPicker", () => { }); }); + // ── Listbox semantics (DC-13) ───────────────────────────────────────────── + + describe("listbox semantics", () => { + it("marks the grid area as a listbox named GIFs", () => { + const { picker } = makePicker(); + const gridArea = picker.element.querySelector(".gp-grid-area"); + expect(gridArea).not.toBeNull(); + expect(gridArea!.getAttribute("role")).toBe("listbox"); + expect(gridArea!.getAttribute("aria-label")).toBe("GIFs"); + picker.destroy(); + }); + + it("gives each item role=option with the gif title as aria-label", async () => { + const { picker } = makePicker(); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const items = picker.element.querySelectorAll(".gp-item"); + expect(items.length).toBe(TRENDING_GIFS.length); + items.forEach((item, i) => { + expect(item.getAttribute("role")).toBe("option"); + expect(item.getAttribute("aria-label")).toBe(TRENDING_GIFS[i]!.title); + }); + picker.destroy(); + }); + + it("aria-label falls back to 'GIF' when the title is empty", async () => { + const gifNoTitle: GifResult = { + id: "no-title", + title: "", + url: "https://media.klipy.com/preview/no-title.gif", + fullUrl: "https://media.klipy.com/full/no-title.gif", + }; + vi.mocked(getTrendingGifs).mockResolvedValue([gifNoTitle]); + + const { picker } = makePicker(); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const item = picker.element.querySelector(".gp-item"); + expect(item!.getAttribute("aria-label")).toBe("GIF"); + picker.destroy(); + }); + + it("makes exactly one item tabbable (roving tabindex)", async () => { + const { picker } = makePicker(); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const items = Array.from(picker.element.querySelectorAll(".gp-item")); + const tabbable = items.filter((c) => c.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + expect(tabbable[0]).toBe(items[0]); + expect(items.slice(1).every((c) => c.getAttribute("tabindex") === "-1")).toBe(true); + picker.destroy(); + }); + + it("re-applies the roving tabindex when a search replaces the items", async () => { + const { picker } = makePicker(); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const input = picker.element.querySelector(".gp-search") as HTMLInputElement; + input.value = "cats"; + input.dispatchEvent(new Event("input")); + vi.advanceTimersByTime(300); + await Promise.resolve(); + await Promise.resolve(); + + const items = Array.from(picker.element.querySelectorAll(".gp-item")); + expect(items.length).toBe(SEARCH_GIFS.length); + const tabbable = items.filter((c) => c.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + expect(tabbable[0]).toBe(items[0]); + picker.destroy(); + }); + + it("ArrowRight moves focus and the tabbable item to the next gif", async () => { + const { picker } = makePicker(); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const items = picker.element.querySelectorAll(".gp-item") as NodeListOf<HTMLElement>; + expect(items.length).toBeGreaterThan(1); + + items[0]!.focus(); + items[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + + expect(document.activeElement).toBe(items[1]); + expect(items[0]!.getAttribute("tabindex")).toBe("-1"); + expect(items[1]!.getAttribute("tabindex")).toBe("0"); + picker.destroy(); + }); + + it("Enter on a focused item fires the same onSelect/onClose as click", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const { picker } = makePicker({ onSelect, onClose }); + container.appendChild(picker.element); + + await Promise.resolve(); + await Promise.resolve(); + + const firstItem = picker.element.querySelector(".gp-item") as HTMLElement; + firstItem.focus(); + firstItem.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSelect).toHaveBeenCalledWith(TRENDING_GIFS[0]!.fullUrl); + expect(onClose).toHaveBeenCalledOnce(); + picker.destroy(); + }); + }); + // ── destroy() ───────────────────────────────────────────────────────────── describe("destroy()", () => { diff --git a/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts index d043dea1..fef8290b 100644 --- a/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts +++ b/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts @@ -115,4 +115,44 @@ describe("IdentityMismatchModal", () => { const title = container.querySelector(".cert-title"); expect(title?.textContent).toBe("Identity Key Changed"); }); + + it("carries dialog semantics labelled by the h3 title", () => { + mountModal(); + const modal = container.querySelector(".modal"); + expect(modal?.getAttribute("role")).toBe("dialog"); + expect(modal?.getAttribute("aria-modal")).toBe("true"); + expect(modal?.getAttribute("aria-labelledby")).toBe("identity-mismatch-title"); + const title = container.querySelector("#identity-mismatch-title"); + expect(title?.textContent).toBe("Identity Warning"); + }); + + it("gives the icon-only close button an accessible name", () => { + mountModal(); + const btn = container.querySelector(".modal-close"); + expect(btn?.getAttribute("aria-label")).toBe("Close"); + }); + + it("calls onReject when Escape is pressed", () => { + const { onReject } = mountModal(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("does not call onReject on Escape after destroy", () => { + const { modal, onReject } = mountModal(); + modal.destroy?.(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("moves focus inside the modal on mount and restores it on destroy", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + const { modal } = mountModal(); + expect(container.contains(document.activeElement)).toBe(true); + modal.destroy?.(); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); }); diff --git a/Client/tauri-client/tests/unit/identity.test.ts b/Client/tauri-client/tests/unit/identity.test.ts index ad5d681e..8477c083 100644 --- a/Client/tauri-client/tests/unit/identity.test.ts +++ b/Client/tauri-client/tests/unit/identity.test.ts @@ -54,19 +54,28 @@ describe("identity keyring wrappers", () => { expect(invokeMock).toHaveBeenCalledWith("delete_identity_key", { host: "chat.example" }); }); - it("returns false/null and swallows errors when a command rejects", async () => { + it("returns false and swallows errors when a command rejects (save/delete)", async () => { invokeMock.mockRejectedValue(new Error("keyring boom")); expect(await saveIdentityKey("h", "k")).toBe(false); - expect(await loadIdentityKey("h")).toBeNull(); expect(await deleteIdentityKey("h")).toBe(false); }); + + it("loadIdentityKey rethrows (does not swallow) when the command rejects", async () => { + // A keyring read error must not be indistinguishable from "nothing + // stored" — loadOrGenerateIdentityKeyPair uses a null return to decide + // whether to mint a brand-new identity keypair, so swallowing an error + // into null here mints and publishes a fresh identity on every transient + // store failure, invalidating every peer's TOFU pin. + invokeMock.mockRejectedValueOnce(new Error("keyring boom")); + await expect(loadIdentityKey("h")).rejects.toThrow("keyring boom"); + }); }); describe("identity pin wrappers", () => { it("storeIdentityPin invokes store_identity_pin with { host, userId, pin }", async () => { invokeMock.mockResolvedValue(undefined); - const ok = await storeIdentityPin("chat.example", "42", "pubkey"); - expect(ok).toBe(true); + const result = await storeIdentityPin("chat.example", "42", "pubkey"); + expect(result).toBe("stored"); expect(invokeMock).toHaveBeenCalledWith("store_identity_pin", { host: "chat.example", userId: "42", @@ -74,16 +83,49 @@ describe("identity pin wrappers", () => { }); }); - it("getIdentityPin returns the pinned key, or null when never pinned", async () => { + it("storeIdentityPin reports 'failed' (not silently truthy/falsy) when the write rejects", async () => { + // The whole point of the tri-state result: a real write error (disk + // full, unwritable pins file) must be distinguishable from "no-store" + // (non-Tauri, by design) — collapsing both to `false` let a caller + // treat a failed write the same as "nothing to persist" and still show + // "verified" with no pin ever saved. + invokeMock.mockRejectedValueOnce(new Error("disk full")); + const result = await storeIdentityPin("chat.example", "42", "pubkey"); + expect(result).toBe("failed"); + expect(logMock.error).toHaveBeenCalledWith( + "Failed to store identity pin", + expect.objectContaining({ host: "chat.example", userId: "42" }), + ); + }); + + it("getIdentityPin returns the pinned key when one is stored", async () => { invokeMock.mockResolvedValueOnce("pubkey"); - expect(await getIdentityPin("chat.example", "42")).toBe("pubkey"); - invokeMock.mockResolvedValueOnce(null); - expect(await getIdentityPin("chat.example", "42")).toBeNull(); + expect(await getIdentityPin("chat.example", "42")).toEqual({ + status: "pinned", + pin: "pubkey", + }); expect(invokeMock).toHaveBeenCalledWith("get_identity_pin", { host: "chat.example", userId: "42", }); }); + + it("getIdentityPin reports 'unpinned' when the store holds nothing (first sight)", async () => { + invokeMock.mockResolvedValueOnce(null); + expect(await getIdentityPin("chat.example", "42")).toEqual({ status: "unpinned" }); + }); + + it("getIdentityPin reports a store error as 'unavailable', never 'unpinned' (DC-08)", async () => { + // The distinction is the whole fix: a transient keyring failure must not + // masquerade as "never pinned", or verification silently falls through to + // the first-sight path and re-pins whatever key the server delivered. + invokeMock.mockRejectedValueOnce(new Error("keyring boom")); + expect(await getIdentityPin("chat.example", "42")).toEqual({ status: "unavailable" }); + expect(logMock.error).toHaveBeenCalledWith( + expect.stringContaining("unavailable"), + expect.objectContaining({ host: "chat.example", userId: "42" }), + ); + }); }); describe("getOrCreateIdentityKeyPair", () => { @@ -186,6 +228,18 @@ describe("getOrCreateIdentityKeyPair", () => { }); }); + it("aborts instead of regenerating when the keyring read fails (does not overwrite an unreadable identity)", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.reject(new Error("keychain locked")); + return Promise.resolve(undefined); + }); + + await expect(getOrCreateIdentityKeyPair("chat.example")).rejects.toThrow("keychain locked"); + // Must not have minted and saved a brand-new identity over the top of an + // unreadable (not necessarily absent) stored key. + expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false); + }); + it("stays quiet when the store round-trips the key", async () => { let savedBlob: string | undefined; invokeMock.mockImplementation((cmd: string, args?: Record<string, unknown>) => { @@ -276,6 +330,20 @@ describe("ensureIdentityKeyPublished (login/ready publish flow)", () => { expect(second).not.toHaveBeenCalled(); }); + it("does not mint/publish a new identity key when the keyring read fails (fire-and-forget)", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.reject(new Error("keychain locked")); + return Promise.resolve(undefined); + }); + const updateProfile = vi.fn().mockResolvedValue({}); + + const published = await ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile); + + expect(published).toBe(false); + expect(updateProfile).not.toHaveBeenCalled(); + expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false); + }); + it("swallows a failing profile update (fire-and-forget, never throws)", async () => { invokeMock.mockImplementation((cmd: string) => { if (cmd === "load_identity_key") return Promise.resolve(null); diff --git a/Client/tauri-client/tests/unit/invite-manager.test.ts b/Client/tauri-client/tests/unit/invite-manager.test.ts index 25d6ea4c..eaa5e92e 100644 --- a/Client/tauri-client/tests/unit/invite-manager.test.ts +++ b/Client/tauri-client/tests/unit/invite-manager.test.ts @@ -232,6 +232,50 @@ describe("InviteManager", () => { mgr.destroy?.(); }); + // ── dialog accessibility contract (DC-13) ────────────────────────────────── + + it("stamps dialog semantics named by the header title", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const dialog = container.querySelector(".modal") as HTMLElement; + expect(dialog.getAttribute("role")).toBe("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-labelledby")).toBe("invite-manager-title"); + expect(container.querySelector("#invite-manager-title")?.textContent).toBe("Server Invites"); + + mgr.destroy?.(); + }); + + it("labels the icon-only close button", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(closeBtn.getAttribute("aria-label")).toBe("Close"); + + mgr.destroy?.(); + }); + + it("moves focus into the modal on mount and restores it on destroy", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + // First focusable in DOM order is the header close button. + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(document.activeElement).toBe(closeBtn); + + mgr.destroy?.(); + expect(document.activeElement).toBe(trigger); + }); + it("revoke failure calls onError", async () => { const opts = makeOptions({ onRevokeInvite: vi.fn(() => Promise.reject(new Error("fail"))), diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts index 97e587f7..4924e1f4 100644 --- a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -43,8 +43,8 @@ vi.mock("@lib/e2eeCrypto", () => ({ vi.mock("@lib/identity", () => ({ getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair), - getIdentityPin: vi.fn(async () => null), - storeIdentityPin: vi.fn(async () => true), + getIdentityPin: vi.fn(async () => ({ status: "unpinned" })), + storeIdentityPin: vi.fn(async () => "stored"), })); vi.mock("@stores/auth.store", () => ({ @@ -79,7 +79,15 @@ vi.mock("@lib/logger", () => ({ // Now import import { E2EEManager } from "../../src/lib/livekitE2EE"; -import { setPeerVerification } from "@stores/voice.store"; +import { setPeerVerification, clearPeerVerification } from "@stores/voice.store"; +import { + unwrapRoomKey, + roomKeyToBase64, + wrapRoomKey, + generateECDHKeyPair, + importPublicKey, +} from "@lib/e2eeCrypto"; +import { getOrCreateIdentityKeyPair, storeIdentityPin } from "@lib/identity"; const PEER_ID = 42; @@ -100,6 +108,7 @@ describe("E2EEManager", () => { vi.clearAllMocks(); mockMembers.clear(); mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" }); + mockVoiceState.voiceUsers.clear(); }); it("setupKeyExchange as key holder generates the room key and sends a signed announce", async () => { @@ -126,18 +135,21 @@ describe("E2EEManager", () => { await mgr.setupKeyExchange(true, 1); - // Drained through the verifying receive path and stored. No offer yet: - // the drain runs before the room key is generated. + // Drained through the verifying receive path and stored. The room key is + // generated BEFORE the drain, so each drained peer gets its offer right + // here — mid-call peers never re-announce, and the only other delivery + // is the 5-minute rotation timer, which would strand them on a dead key + // whenever a new key holder joins an ongoing call. expect(mgr.pendingAnnounces).toHaveLength(0); expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); expect(setPeerVerification).toHaveBeenCalledWith( expect.objectContaining({ userId: PEER_ID, status: "verified" }), ); - expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); // A repeat announce after keying (dedupe path) re-sends the room-key offer. await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); - expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(2); }); it("setupKeyExchange as non-key-holder resolves once the key holder's offer arrives", async () => { @@ -175,6 +187,109 @@ describe("E2EEManager", () => { expect(mgr.peerPublicKeys.size).toBe(0); }); + it("elects a still-connecting client when the holder leaves mid-setup, instead of stranding it", async () => { + const ws = { send: vi.fn() }; + // The session publishes no channel id for the whole "connecting" phase, + // which spans the entire key-exchange wait. + const mgr = new E2EEManager({ + getWs: () => ws as never, + getServerHost: () => "localhost:7880", + getCurrentChannelId: () => null, + }); + // After the old holder (PEER_ID) leaves, we (uid 1) are the only participant. + mockVoiceState.voiceUsers.set(1, new Map([[1, {}]])); + + const setupPromise = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + + // The holder leaves before offering, while we are still inside + // setupKeyExchange. The re-election must use the channel id the exchange + // was started with (getCurrentChannelId is still null) and must unblock + // the wait — the offer we were waiting for will never arrive. + await mgr.handleParticipantLeft(PEER_ID); + + await expect(setupPromise).resolves.toBe(true); + expect(mgr.epoch).toBe(1); // became holder and generated a fresh key + expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64"); + }); + + it("applies concurrent offers in arrival order, not completion order", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // establishes our keypair + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // known peer + mockSetKey.mockClear(); + + // The first offer's unwrap stalls (WebCrypto gives no cross-operation + // ordering guarantee); the second resolves immediately. Delivery order + // must still win — otherwise the receiver ends on the first (dead) key. + let releaseFirst!: () => void; + const firstUnwrap = new Promise<Uint8Array>((resolve) => { + releaseFirst = () => resolve(new Uint8Array(32).fill(1)); + }); + vi.mocked(unwrapRoomKey) + .mockReturnValueOnce(firstUnwrap) + .mockResolvedValueOnce(new Uint8Array(32).fill(2)); + vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`); + try { + const first = mgr.handleOffer(PEER_ID, "enc1", "iv1"); + const second = mgr.handleOffer(PEER_ID, "enc2", "iv2"); + releaseFirst(); + await Promise.all([first, second]); + + // The second (newer) key must be the one left applied. + expect(mockSetKey).toHaveBeenLastCalledWith("key-2"); + } finally { + vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64"); + } + }); + + it("stops rotating once it accepts an offer, so a re-elected holder is not fought", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + // We joined first, so the server elected us key holder. + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + // A lower-userID participant then joined; the server re-elected them and + // they sent us the room key. Accepting an offer proves they are the + // server-authoritative holder (the server gates offers on IsVoiceKeyHolder). + await mgr.handleOffer(PEER_ID, "enc", "iv"); + const epochAfterOffer = mgr.epoch; + ws.send.mockClear(); + + // The stale rotation timer must no longer rotate: the server rejects those + // offers with NOT_KEY_HOLDER, but only after we have already applied the new + // key locally — leaving us deaf and mute until the real holder rotates again. + await mgr.rotateKeyPeriodically(); + + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + expect(mgr.epoch).toBe(epochAfterOffer); + }); + + it("keeps peer public keys across a reconnect so a later offer can still be unwrapped", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + + await mgr.reannounceForReconnect(); + + // Nothing repopulates this map after an SFU-level reconnect: handleAnnounce + // replies with an offer rather than a counter-announce, and the server + // relays stored peer keys only on voice_join. Peers' public keys stay valid + // when we regenerate our own pair, so dropping them only strands us on the + // pre-reconnect key. + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + + mockSetKey.mockClear(); + await mgr.handleOffer(PEER_ID, "enc", "iv"); + expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64"); + }); + it("rotateKeyPeriodically advances the epoch and redistributes the key to peers", async () => { const ws = { send: vi.fn() }; const mgr = createManager(ws); @@ -189,4 +304,465 @@ describe("E2EEManager", () => { expect(offers).toHaveLength(1); expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID); }); + + // ── Batch C3 findings ─────────────────────────────────────────────────── + + it("[finding 1] resumes as key holder when re-elected while a prior rotation is still in flight", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + mockVoiceState.voiceUsers.set(1, new Map([[1, {}]])); // we (uid 1) are always lowest + + await mgr.setupKeyExchange(true, 1); // epoch 1, holder + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // peer key known + + // Start a periodic rotation and stall it right after the epoch bump, at + // the keyProvider.setKey await — mirrors an in-flight become-holder + // rotation (same _rotatingKey guard). + let releaseRotationSetKey!: () => void; + const stall = new Promise<void>((resolve) => { + releaseRotationSetKey = resolve; + }); + mockSetKey.mockImplementationOnce(() => stall); + const rotationPromise = mgr.rotateKeyPeriodically(); + expect(mgr.rotatingKey).toBe(true); + expect(mgr.epoch).toBe(2); + + // While that rotation is in flight, an offer from the "real" elected + // holder arrives and stands us down (handleOfferInner clears + // _isKeyHolder but not _rotatingKey). + await mgr.handleOffer(PEER_ID, "enc", "iv"); + + // The new holder immediately leaves — we are re-elected. This must not + // be a silent no-op just because a rotation is still in flight. + await mgr.handleParticipantLeft(99); + + // Let the original (stalled) rotation finish. + releaseRotationSetKey(); + await rotationPromise; + + // The re-election's finally -> drainPendingRotationOrArmTimer must have + // run a fresh rotation as holder: one more epoch bump and key-provider + // update beyond the two already accounted for (initial holder setup + + // the stalled rotation). + expect(mgr.epoch).toBe(3); + expect(mgr.rotatingKey).toBe(false); + }); + + it("[finding 2] marks a first-sight peer 'unverified' (not 'verified') when the identity-pin write fails", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // establishes our keypair + vi.mocked(storeIdentityPin).mockResolvedValueOnce("failed"); + + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(storeIdentityPin).toHaveBeenCalledWith( + "localhost:7880", + String(PEER_ID), + "peer-identity-b64", + ); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "unverified", safetyNumber: null }), + ); + expect(setPeerVerification).not.toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "verified" }), + ); + }); + + it("[finding 3] discards a stale offer even when epoch is unchanged, because the keypair belongs to a cleared session", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + // Session 1 (non-key-holder): our keypair + the peer's key. + const setup1 = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + // This offer's unwrap stalls — still in flight when the user leaves voice. + let releaseUnwrap!: (v: Uint8Array) => void; + const stalledUnwrap = new Promise<Uint8Array>((resolve) => { + releaseUnwrap = resolve; + }); + vi.mocked(unwrapRoomKey).mockReturnValueOnce(stalledUnwrap); + const offerPromise = mgr.handleOffer(PEER_ID, "enc-old", "iv-old"); + await vi.waitFor(() => expect(unwrapRoomKey).toHaveBeenCalled()); + + // Leave voice mid-unwrap. + mgr.clearState(); + await expect(setup1).resolves.toBe(false); + + // A brand-new voice session generates a fresh keypair (forward secrecy). + // Epoch is 0 in both sessions (non-key-holder never bumps it), so only + // keypair identity can distinguish session 1's stale offer from session 2. + const newKeyPair = { + publicKey: { type: "public-2" } as unknown as CryptoKey, + privateKey: { type: "private-2" } as unknown as CryptoKey, + }; + vi.mocked(generateECDHKeyPair).mockResolvedValueOnce(newKeyPair); + await mgr.reannounceForReconnect(); + mockSetKey.mockClear(); + + // The stale session-1 offer now resolves. + releaseUnwrap(new Uint8Array(32).fill(9)); + await offerPromise; + + // Must be discarded: epoch alone (0 === 0) would have let it through. + expect(mockSetKey).not.toHaveBeenCalled(); + }); + + it("[finding 4] discards a stale announce-offer if the room key rotates while wrapRoomKey is in flight", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // holder, epoch 1 + + let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void; + const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => { + releaseWrap = resolve; + }); + vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap); + + const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalled()); + + // A rotation completes while the announce's own wrap is still pending — + // it sees PEER_ID already in _peerPublicKeys (stored synchronously before + // the stalled wrap) and sends it the fresh key. + await mgr.rotateKeyPeriodically(); + ws.send.mockClear(); + + // The stale (pre-rotation) wrap now resolves. + releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" }); + await announcePromise; + + // Must be discarded — the peer already has the fresh key from rotation. + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + }); + + it("[finding 5] retries with a fresh promise after a decrypt failure, so a second good offer still completes setup", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + vi.mocked(unwrapRoomKey).mockRejectedValueOnce(new Error("bad offer")); + + const setupPromise = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + // A corrupt/undecryptable offer arrives — handleOfferInner's catch + // rejects the wait. + await mgr.handleOffer(PEER_ID, "enc-bad", "iv-bad"); + + // The retry must re-announce and give a FRESH window — a second, valid + // offer must still be able to complete setup. + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(1); + }); + await mgr.handleOffer(PEER_ID, "enc-good", "iv-good"); + + await expect(setupPromise).resolves.toBe(true); + }); + + it("[finding 6] queues (does not drop) a live announce that arrives during setup, before isKeyHolder/roomKey are ready", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + // Stall the identity-key load inside buildAnnouncePayload so a "live" + // handleAnnounce can land while setup is still in its prefix — before + // _isKeyHolder/_roomKey (and, with the fix, _ecdhKeyPair) are ready. + let releaseIdentity!: () => void; + const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => { + releaseIdentity = () => resolve(mockIdentityKeyPair); + }); + vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity); + + const setupPromise = mgr.setupKeyExchange(true, 1); // becoming key holder + await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled()); + + // A peer announces "live" (server-relayed) while our own setup is still + // in flight. + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + releaseIdentity(); + await setupPromise; + + // The peer must receive a room-key offer — either queued-then-drained, or + // handled live after isKeyHolder/roomKey became ready. It must never be + // silently stored with no offer ever sent. + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + }); + + // ── Batch ts:livekit-e2ee findings ────────────────────────────────────── + + it("[finding v011] keeps the mismatch block when the re-pin write fails", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + vi.mocked(storeIdentityPin).mockResolvedValueOnce("failed"); + + const result = await mgr.rePinPeerIdentity(PEER_ID, "verified-key-b64"); + + // A failed write must not report the re-pin as successful — the OLD pin + // is still on disk, so clearing the mismatch block here would let the + // UI claim trust was re-established when it wasn't. + expect(result).toBe(false); + expect(clearPeerVerification).not.toHaveBeenCalled(); + }); + + it("[finding v011] clears the mismatch block when the re-pin write succeeds", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + const result = await mgr.rePinPeerIdentity(PEER_ID, "verified-key-b64"); + + expect(result).toBe(true); + expect(clearPeerVerification).toHaveBeenCalledWith(PEER_ID); + }); + + it("[finding v043] a superseded attempt's retry guard checks keypair ownership, not just null, so it never re-announces a dead key over a live session", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + const keypairA = { + publicKey: { type: "pubA" } as unknown as CryptoKey, + privateKey: { type: "privA" } as unknown as CryptoKey, + }; + const keypairB = { + publicKey: { type: "pubB" } as unknown as CryptoKey, + privateKey: { type: "privB" } as unknown as CryptoKey, + }; + vi.mocked(generateECDHKeyPair).mockResolvedValueOnce(keypairA).mockResolvedValueOnce(keypairB); + + // Session A: non-key-holder, waiting for the key holder's offer. + const setupA = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + + // Session A is superseded by a fresh session B (e.g. the user left and + // rejoined) before A's 10s window elapses. B publishes its own keypair, + // overwriting A's — a plain `=== null` check on _ecdhKeyPair can't see + // this, since it is now non-null (B's). + const setupB = mgr.setupKeyExchange(true, 2); + await expect(setupB).resolves.toBe(true); + + // Re-populate the peer key B's setup wiped, so a decrypt failure can + // reach A's still-installed rejector without an early return. + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + ws.send.mockClear(); + + // A corrupt offer rejects A's still-pending roomKeyPromise via its + // rejector — the only field B's (key-holder) setup never touches. + vi.mocked(unwrapRoomKey).mockRejectedValueOnce(new Error("bad offer")); + await mgr.handleOffer(PEER_ID, "enc-bad", "iv-bad"); + + // A must give up instead of re-announcing its dead ephemeral key over + // B's live session and reinstalling a resolver nothing will ever call. + await expect(setupA).resolves.toBe(false); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0); + }); + + it("[finding v043] a superseded setup does not wipe the live session's peer keys", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + // Attempt A stalls inside generateECDHKeyPair — before it has written + // anything, so clearState() has no rejector to reject and A survives its + // own teardown. + let releaseGen!: (v: CryptoKeyPair) => void; + const stalledGen = new Promise<CryptoKeyPair>((resolve) => { + releaseGen = resolve; + }); + vi.mocked(generateECDHKeyPair).mockReturnValueOnce(stalledGen); + const setupA = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => expect(generateECDHKeyPair).toHaveBeenCalled()); + + // The user leaves and joins another channel; that session completes and + // learns a peer's ECDH key. + mgr.clearState(); + await expect(mgr.setupKeyExchange(true, 2)).resolves.toBe(true); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + // Attempt A only now resumes, long after its own teardown. + releaseGen({ + publicKey: { type: "stale-pub" } as unknown as CryptoKey, + privateKey: { type: "stale-priv" } as unknown as CryptoKey, + }); + await expect(setupA).resolves.toBe(false); + + // The live session's peer key must survive: nothing repopulates this map + // mid-call, so clearing it here would make handleOffer's unknown-peer + // guard drop every later rotation from that peer. + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true); + }); + + it("[finding v043] a superseded setup does not clobber the live session's key-holder role", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + // Attempt A (non-key-holder) stalls inside buildAnnouncePayload's keyring + // round trip — the widest pre-publication window. + let releaseIdentity!: () => void; + const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => { + releaseIdentity = () => resolve(mockIdentityKeyPair); + }); + vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity); + const setupA = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled()); + + // The user leaves and joins another channel as key holder. + mgr.clearState(); + await expect(mgr.setupKeyExchange(true, 2)).resolves.toBe(true); + const epochAfterLiveSetup = mgr.epoch; + + releaseIdentity(); + await expect(setupA).resolves.toBe(false); + ws.send.mockClear(); + + // A's `_isKeyHolder = false` must never land on the live session: it + // would silently stop its rotations AND its offers to peers that + // announce later, with nothing to re-elect it. + expect(mgr.epoch).toBe(epochAfterLiveSetup); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + }); + + it("[finding v015] applies concurrent announces in arrival order, not completion order", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // establishes our keypair + room key + + // The first announce's key import stalls; the second is unimpeded. WS + // delivery order must still win — otherwise _peerPublicKeys ends up on + // the peer's superseded key and every later rotation is wrapped for a + // private key they no longer hold. + const keyOne = { type: "peer-key-1" } as unknown as CryptoKey; + const keyTwo = { type: "peer-key-2" } as unknown as CryptoKey; + let releaseFirst!: (v: CryptoKey) => void; + const firstImport = new Promise<CryptoKey>((resolve) => { + releaseFirst = resolve; + }); + vi.mocked(importPublicKey).mockReturnValueOnce(firstImport).mockResolvedValueOnce(keyTwo); + + const first = mgr.handleAnnounce(PEER_ID, "b2xk", "sig1"); + const second = mgr.handleAnnounce(PEER_ID, "bmV3", "sig2"); + // Drain every pending microtask: unserialized, the second announce runs + // to completion right here while the first is still stalled. + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirst(keyOne); + await Promise.all([first, second]); + + expect(mgr.peerPublicKeys.get(PEER_ID)).toBe(keyTwo); + }); + + it("[finding v093] does not resurrect the keypair or send a stray announce if clearState() runs during reannounceForReconnect", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + ws.send.mockClear(); + + let releaseGen!: (v: CryptoKeyPair) => void; + const stalledGen = new Promise<CryptoKeyPair>((resolve) => { + releaseGen = resolve; + }); + vi.mocked(generateECDHKeyPair).mockReturnValueOnce(stalledGen); + + const reconnectPromise = mgr.reannounceForReconnect(); + await vi.waitFor(() => expect(generateECDHKeyPair).toHaveBeenCalled()); + + // The user disconnects while the reconnect's keypair generation is + // still in flight. + mgr.clearState(); + + releaseGen({ + publicKey: { type: "reconnect-pub" } as unknown as CryptoKey, + privateKey: { type: "reconnect-priv" } as unknown as CryptoKey, + }); + await reconnectPromise; + + // Must not resurrect the torn-down keypair or send a stray announce for + // a channel we already left. + expect((mgr as unknown as { _ecdhKeyPair: unknown })._ecdhKeyPair).toBeNull(); + expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0); + }); + + it("[finding v101] discards a stale announce-offer if the keypair (not epoch) changes while wrapRoomKey is in flight", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); // holder, epoch 1 + + let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void; + const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => { + releaseWrap = resolve; + }); + vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap); + + const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalled()); + + // A concurrent reconnect swaps the keypair WITHOUT bumping the epoch — + // an epoch-only staleness check would miss this entirely. + vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({ + publicKey: { type: "public-2" } as unknown as CryptoKey, + privateKey: { type: "private-2" } as unknown as CryptoKey, + }); + await mgr.reannounceForReconnect(); + ws.send.mockClear(); + + // The stale (pre-reconnect) wrap now resolves. + releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" }); + await announcePromise; + + expect(mgr.epoch).toBe(1); // epoch never moved — proves the epoch-only check would miss this + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + }); + + it("[finding v045] aborts room-key distribution mid-loop when a concurrent reconnect swaps the keypair", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + mockVoiceState.voiceUsers.set(1, new Map([[1, {}]])); // we (uid 1) are always lowest + + await mgr.setupKeyExchange(true, 1); // epoch 1, holder + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + await mgr.handleAnnounce(PEER_ID + 1, "cGVlcjI=", "sig2"); + + // A lower-userID peer's offer stands us down (mirrors the "stops + // rotating" test above), so the next participant-left re-elects us. + await mgr.handleOffer(PEER_ID, "enc", "iv"); + expect(mgr.rotatingKey).toBe(false); + + // Stall the first peer's wrap in the become-holder redistribution loop. + // (wrapRoomKey was already called twice above, wrapping for each peer's + // announce — clear that history so the count below tracks only the + // redistribution loop.) + vi.mocked(wrapRoomKey).mockClear(); + let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void; + const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => { + releaseWrap = resolve; + }); + vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap); + + const leftPromise = mgr.handleParticipantLeft(99); // re-elects us as holder + await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalledTimes(1)); + ws.send.mockClear(); + + // A concurrent reconnect swaps the keypair mid-loop, without bumping + // the epoch. + vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({ + publicKey: { type: "reconnect-pub" } as unknown as CryptoKey, + privateKey: { type: "reconnect-priv" } as unknown as CryptoKey, + }); + await mgr.reannounceForReconnect(); + ws.send.mockClear(); + + releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" }); + await leftPromise; + + // Neither peer should receive an offer wrapped under the abandoned + // keypair — the loop must abort as soon as it notices the swap. + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + }); }); diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 50e03140..f627cfeb 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -5,8 +5,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const mockVoiceState = vi.hoisted(() => ({ localMuted: false, localDeafened: false, + localServerMuted: false, + localServerDeafened: false, + localCamera: false, + localScreenshare: false, })); +/** Backing cell for the mocked voice.store PTT-poller-live flag. Boxed so the + * hoisted mock factory can mutate it after hoisting. */ +const mockPttPollingLive = vi.hoisted(() => ({ value: false })); + const mockRoom = vi.hoisted(() => ({ connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), @@ -61,6 +69,7 @@ vi.mock("livekit-client", () => ({ ExternalE2EEKeyProvider: vi.fn(() => ({ setKey: vi.fn(), getKeys: vi.fn().mockReturnValue([]), + removeAllListeners: vi.fn(), })), createLocalVideoTrack: vi.fn(async () => ({ kind: "video", @@ -82,6 +91,15 @@ vi.mock("@stores/voice.store", () => ({ setLocalDeafened: vi.fn(), setLocalCamera: vi.fn(), setLocalScreenshare: vi.fn(), + setPttGated: vi.fn(), + // The PTT-poller-live flag lives in the store (so ptt.ts can write it at + // startup without importing the LiveKit SDK), so the mock has to carry real + // read/write behaviour rather than a bare vi.fn — restoreLocalVoiceState + // reads it back through isPttPollingLive(). + setPttPollingLive: vi.fn((live: boolean) => { + mockPttPollingLive.value = live; + }), + isPttPollingLive: vi.fn(() => mockPttPollingLive.value), setSpeakers: vi.fn(), leaveVoiceChannel: vi.fn(), setListenOnly: vi.fn(), @@ -156,20 +174,29 @@ vi.mock("@lib/e2eeCrypto", () => ({ // F3 TOFU: identity keyring + peer pin store (Tauri-backed; mocked here). vi.mock("@lib/identity", () => ({ getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair), - getIdentityPin: vi.fn(async () => null), + getIdentityPin: vi.fn(async () => ({ status: "unpinned" })), storeIdentityPin: vi.fn(async () => true), })); -// Stub Worker for E2EE web worker (not available in Node/vitest) -globalThis.Worker = vi.fn() as unknown as typeof Worker; +// Stub Worker for E2EE web worker (not available in Node/vitest). Instances +// carry a terminate() mock so worker-lifecycle assertions can observe teardown. +globalThis.Worker = vi.fn(function (this: { terminate: () => void }) { + this.terminate = vi.fn(); +}) as unknown as typeof Worker; // Now import -import { parseUserId, LiveKitSession, getRoomForStats } from "../../src/lib/livekitSession"; +import { + parseUserId, + LiveKitSession, + getRoomForStats, + setPttPollingLive, +} from "../../src/lib/livekitSession"; import { setLocalMuted, setLocalDeafened, setLocalCamera, setLocalScreenshare, + setPttGated, setListenOnly, leaveVoiceChannel, setVoiceStatus, @@ -278,6 +305,10 @@ describe("LiveKitSession", () => { vi.useFakeTimers(); mockVoiceState.localMuted = false; mockVoiceState.localDeafened = false; + mockVoiceState.localServerMuted = false; + mockVoiceState.localServerDeafened = false; + mockVoiceState.localCamera = false; + mockVoiceState.localScreenshare = false; session = new LiveKitSession(); // Reset mockRoom state mockRoom.state = "connected"; @@ -681,6 +712,196 @@ describe("LiveKitSession", () => { expect(mockRoom.startAudio).toHaveBeenCalledTimes(1); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledTimes(1); }); + + // v001 regression: join generations must stay monotonic across a + // mid-attempt reset to "idle" (e.g. leaveVoice() from a VOICE_MOVED / + // manual-leave-then-rejoin sequence), even when a stale attempt's + // room.connect() happens to resolve BEFORE the newer attempt's. Before + // the fix, the generation was re-derived from `_state` and restarted at + // 1 after "idle", so the stale and newer attempts collided on the same + // generation and the stale one could win the race and overwrite the + // shared session state with the channel the user was just moved away + // from. + it("keeps a stale attempt from winning after a mid-attempt idle reset (v001)", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + const connect1 = createDeferred<void>(); + const connect2 = createDeferred<void>(); + mockRoom.connect + .mockImplementationOnce(() => connect1.promise) + .mockImplementationOnce(() => connect2.promise); + + // Attempt 1 (channel 1) stalls inside room.connect(). + const attempt1 = (session as any).connectAndSetup( + "token-1", + "/livekit", + 1, + "ws://localhost:7880", + true, + ); + await vi.advanceTimersByTimeAsync(0); + + // Superseding event: resets to idle WITHOUT tearing down attempt 1's + // room (leaveVoice's `_room` getter is null while "connecting"). + session.leaveVoice(false); + + // Attempt 2 (channel 2) starts from idle and also stalls in connect(). + const attempt2 = (session as any).connectAndSetup( + "token-2", + "/livekit", + 2, + "ws://localhost:7880", + true, + ); + await vi.advanceTimersByTimeAsync(0); + + // Attempt 1's connect resolves FIRST even though it is the stale one. + connect1.resolve(undefined); + const result1 = await attempt1; + expect(result1).toBe("superseded"); + + // Attempt 2 must still own the shared state — attempt 1 must not have + // installed channel 1 as "connected" over it. + expect((session as any)._state.type).toBe("connecting"); + + connect2.resolve(undefined); + const result2 = await attempt2; + expect(result2).toBe(true); + expect((session as any)._state.type).toBe("connected"); + expect((session as any)._state.channelId).toBe(2); + }); + + // v003 + v010 + v004 all rely on connectAndSetup's outer catch only + // touching shared state when the attempt is still current. This locks + // the "not superseded" half: on a genuine connect failure the client + // must send voice_leave so the server doesn't keep a ghost voice_states + // row for a join that never reached the SFU. + it("sends voice_leave and leaves the voice channel on a genuine connect failure (v003)", async () => { + const ws = { send: vi.fn() }; + session.setServerHost("localhost:7880"); + session.setWsClient(ws as any); + + mockRoom.connect.mockRejectedValue(new Error("connection refused")); + + const resultPromise = session.handleVoiceToken( + "test-token", + "/livekit", + 1, + "ws://localhost:7880", + true, + ); + + for (let i = 0; i < 3; i++) { + await vi.advanceTimersByTimeAsync(2100); + } + await resultPromise; + + expect(ws.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + expect(leaveVoiceChannel).toHaveBeenCalled(); + }); + + // v010: setupKeyExchange() returns false both for a genuine timeout AND + // for an aborted wait (clearState() ran because a newer attempt + // superseded this one). The failure block must not fire the spurious + // "e2ee_timeout" toast / voice_leave / leaveVoiceChannel() for the + // aborted case, or it corrupts the newer attempt's just-established + // server-side membership. + it("does not fire e2ee_timeout cleanup when superseded during key exchange (v010)", async () => { + const errorCb = vi.fn(); + session.setOnError(errorCb); + session.setServerHost("localhost:7880"); + const ws = { send: vi.fn() }; + session.setWsClient(ws as any); + + const keyExchangeSpy = vi + .spyOn((session as any)._e2ee, "setupKeyExchange") + .mockImplementation(async () => { + // Simulate a newer attempt superseding this one WHILE this attempt + // is blocked waiting on the key exchange (mirrors leaveVoice() + // bumping the generation via a fresh connectAndSetup call). + (session as any)._state = { + type: "connecting", + pendingJoin: null, + joinGeneration: 999, + }; + return false; + }); + + const result = await (session as any).connectAndSetup( + "token", + "/livekit", + 1, + "ws://localhost:7880", + false, + ); + + expect(result).toBe("superseded"); + expect(errorCb).not.toHaveBeenCalledWith("e2ee_timeout"); + expect(ws.send).not.toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + expect(leaveVoiceChannel).not.toHaveBeenCalled(); + + keyExchangeSpy.mockRestore(); + }); + }); + + describe("restoreLocalVoiceState PTT gating (v007)", () => { + afterEach(() => { + // setPttPollingLive is a module-level flag shared across tests. + setPttPollingLive(false); + mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal); + }); + + it("mutes at join when a PTT key is bound and the poller is confirmed live", async () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => + key === "pttVk" ? 0x41 : defaultVal, + ); + setPttPollingLive(true); + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true); + + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); + expect(setPttGated).toHaveBeenCalledWith(true); + // The gate must NOT be recorded as a self-mute: ptt.ts refuses to open + // the mic on a PTT press while localMuted is set, so writing it here + // would close the mic for the whole session, not just until the first + // press — the exact "permanently closed mic" failure v007 warns about. + expect(setLocalMuted).not.toHaveBeenCalledWith(true); + }); + + // Gating on the stored key alone (without confirming the poller is + // actually live) would close the mic permanently on macOS (is_key_down + // stub always returns false) and pure-Wayland Linux (DeviceState:: + // checked_new() returns None) — neither platform can ever emit a + // ptt-state event to lift the mute. + it("does not mute at join when a PTT key is bound but the poller is not confirmed live", async () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => + key === "pttVk" ? 0x41 : defaultVal, + ); + // setPttPollingLive intentionally not called — defaults to false. + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true); + + expect(setLocalMuted).not.toHaveBeenCalledWith(true); + expect(setPttGated).toHaveBeenCalledWith(false); + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + }); + + it("does not mute at join when the poller is live but no PTT key is bound", async () => { + setPttPollingLive(true); // pttVk defaults to 0 (disabled) via mockLoadPref + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true); + + expect(setLocalMuted).not.toHaveBeenCalledWith(true); + expect(setPttGated).toHaveBeenCalledWith(false); + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + }); }); describe("voiceStatus transitions (voice-and-e2ee.md §1–2)", () => { @@ -892,6 +1113,60 @@ describe("LiveKitSession", () => { expect((session as any)._screenState.manualScreenTracks).toEqual([]); expect(setLocalScreenshare).toHaveBeenCalledWith(false); }); + + // Without this, a successful auto-reconnect leaves the server's + // voice_states row with camera=1/screenshare=1 forever (the reconnected + // participant is not "rogue" so no webhook clears it), occupying a + // max_video slot the user can never free even by re-toggling. + it("sends voice_camera/voice_screenshare OFF frames before tearing down local tracks", async () => { + session.setServerHost("localhost:7880"); + const sendSpy = vi.fn(); + session.setWsClient({ send: sendSpy } as any); + + let disconnectedHandler: ((reason?: number) => void) | undefined; + mockRoom.on.mockImplementation((event: string, handler: any) => { + if (event === "disconnected") disconnectedHandler = handler; + return mockRoom; + }); + + await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true); + expect(disconnectedHandler).toBeDefined(); + + mockVoiceState.localCamera = true; + mockVoiceState.localScreenshare = true; + sendSpy.mockClear(); + + disconnectedHandler!(/* SERVER_SHUTDOWN */ 1); + + expect(sendSpy).toHaveBeenCalledWith({ type: "voice_camera", payload: { enabled: false } }); + expect(sendSpy).toHaveBeenCalledWith({ + type: "voice_screenshare", + payload: { enabled: false }, + }); + }); + + it("does not send camera/screenshare OFF frames when neither was active", async () => { + session.setServerHost("localhost:7880"); + const sendSpy = vi.fn(); + session.setWsClient({ send: sendSpy } as any); + + let disconnectedHandler: ((reason?: number) => void) | undefined; + mockRoom.on.mockImplementation((event: string, handler: any) => { + if (event === "disconnected") disconnectedHandler = handler; + return mockRoom; + }); + + await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true); + expect(disconnectedHandler).toBeDefined(); + + sendSpy.mockClear(); + disconnectedHandler!(/* SERVER_SHUTDOWN */ 1); + + expect(sendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "voice_camera" })); + expect(sendSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "voice_screenshare" }), + ); + }); }); describe("handleDisconnected during initial connect", () => { @@ -1276,6 +1551,30 @@ describe("LiveKitSession", () => { expect(setupSpy).toHaveBeenCalled(); setupSpy.mockRestore(); }); + + // A moderator's server-mute must not be liftable by the client. The server + // only mutes track SIDs that exist at mute time and the LiveKit grant still + // carries the microphone publish source, so re-publishing a fresh track + // (which unmuting does) is accepted by the SFU: the refusal has to happen + // here, in the one entry point every caller shares. PTT reaches this + // directly, bypassing the widget's own guard. + it("refuses to unmute while server-muted, but still allows muting", async () => { + mockVoiceState.localServerMuted = true; + try { + session.setMuted(false); + await vi.advanceTimersByTimeAsync(0); + + expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true); + expect(setLocalMuted).not.toHaveBeenCalledWith(false); + + // Muting is always allowed — a server-muted user may still mute themselves. + session.setMuted(true); + await vi.advanceTimersByTimeAsync(0); + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); + } finally { + mockVoiceState.localServerMuted = false; + } + }); }); describe("setDeafened (with active room)", () => { @@ -1316,6 +1615,29 @@ describe("LiveKitSession", () => { expect(subSpy).toHaveBeenCalledWith(true); subSpy.mockRestore(); }); + + // v028: mirrors setMuted's server-mute guard. Without it, a moderator + // deafen followed by a moderator "server unmute" leaves + // (localServerDeafened=true, localServerMuted=false) reachable, and a + // client-side undeafen click would resubscribe remote audio and unmute + // the mic even though the server still considers the user deafened. + it("refuses to undeafen while server-deafened, but still allows deafening", async () => { + mockVoiceState.localServerDeafened = true; + try { + session.setDeafened(false); + await vi.advanceTimersByTimeAsync(0); + + expect(setLocalDeafened).not.toHaveBeenCalledWith(false); + expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true); + + // Deafening is always allowed — a server-deafened user may still deafen themselves. + session.setDeafened(true); + await vi.advanceTimersByTimeAsync(0); + expect(setLocalDeafened).toHaveBeenCalledWith(true); + } finally { + mockVoiceState.localServerDeafened = false; + } + }); }); // ----------------------------------------------------------------------- @@ -1401,6 +1723,30 @@ describe("LiveKitSession", () => { expect(setupSpy).toHaveBeenCalled(); setupSpy.mockRestore(); }); + + // Same root cause as the PTT server-mute guard: a listen-only join + // publishes no audio track, so a moderator's server-mute persists but has + // nothing to act on at the SFU. Clicking "Grant Microphone" must not + // publish a fresh, unmuted track the whole channel can hear. + it("keeps the mic muted when server-muted, mirroring the deafened branch", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true); + vi.clearAllMocks(); + mockVoiceState.localServerMuted = true; + + try { + await session.retryMicPermission(); + + expect(setListenOnly).toHaveBeenCalledWith(false); + // applyMicMuteState(true) re-disables the mic it just enabled. + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenLastCalledWith(false); + expect(setLocalMuted).not.toHaveBeenCalledWith(false); + } finally { + mockVoiceState.localServerMuted = false; + } + }); }); // ----------------------------------------------------------------------- @@ -1668,7 +2014,7 @@ describe("LiveKitSession", () => { }); describe("ensureLiveKitProxy", () => { - it("invokes start_livekit_proxy on first call and caches port", async () => { + it("invokes start_livekit_proxy on every call so a re-pinned cert is picked up", async () => { session.setServerHost("example.com:443"); const port1 = await (session as any).ensureLiveKitProxy(); expect(port1).toBe(7881); @@ -1677,10 +2023,15 @@ describe("LiveKitSession", () => { remoteHost: "example.com:443", }); + // A later join must invoke again: only the Rust side can compare the + // running proxy's pin against certs.json after the user accepts a + // rotated cert. A JS port cache would keep every voice rejoin tunneling + // into the stale pin until logout. The Rust reuse branch dedups, so the + // repeat call is cheap. mockInvoke.mockClear(); const port2 = await (session as any).ensureLiveKitProxy(); expect(port2).toBe(7881); - expect(mockInvoke).not.toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalledTimes(1); }); it("appends :443 when serverHost has no port", async () => { @@ -1748,6 +2099,40 @@ describe("LiveKitSession", () => { expect(errorCb).toHaveBeenCalledWith("Failed to join voice — connection error"); }); + it("sends voice_leave and leaves the voice channel when the E2EE key exchange fails", async () => { + const errorCb = vi.fn(); + session.setOnError(errorCb); + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + const keyExchangeSpy = vi + .spyOn((session as any)._e2ee, "setupKeyExchange") + .mockResolvedValue(false); + const leaveSpy = vi.spyOn(session, "leaveVoice"); + + const result = await (session as any).connectAndSetup( + "token", + "/livekit", + 1, + "ws://localhost:7880", + false, + ); + + expect(result).toBe(false); + expect(errorCb).toHaveBeenCalledWith("e2ee_timeout"); + // The exchange times out BEFORE room.connect(), so no SFU participant + // exists and no LiveKit webhook can clean up. Without voice_leave the + // server keeps the voice_states row forever; the ghost survives every + // sweep and, once elected key holder, wedges the channel's E2EE for all + // subsequent joiners. Mirror the reconnect-exhausted give-up path. + expect(mockRoom.connect).not.toHaveBeenCalled(); + expect(leaveSpy).toHaveBeenCalledWith(true); + expect(leaveVoiceChannel).toHaveBeenCalled(); + + keyExchangeSpy.mockRestore(); + leaveSpy.mockRestore(); + }); + it("discards stale join when pendingJoin arrives during connect", async () => { session.setServerHost("localhost:7880"); session.setWsClient({ send: vi.fn() } as any); @@ -1819,6 +2204,66 @@ describe("LiveKitSession", () => { }); }); + describe("connectAndSetup post-connect checkpoints (supersession)", () => { + // Checkpoint 2 (right after room.connect()) already disconnects only its + // OWN localRoom — checkpoints 3/4/5 (after restoreLocalVoiceState and each + // saved-device switch) must do the same, not call the global leaveVoice(). + // A newer attempt can install its own "connected" state into the shared + // _state while an older attempt is still awaiting one of these steps; + // calling the global leaveVoice() at that point tears down whichever + // session currently occupies _state — the NEWER one, not this attempt's. + it("checkpoint 3 does not call the global leaveVoice, and leaves a newer attempt's state untouched", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + const micDeferred = createDeferred<void>(); + mockRoom.localParticipant.setMicrophoneEnabled.mockImplementationOnce( + () => micDeferred.promise, + ); + + const resultPromise = (session as any).connectAndSetup( + "token-A", + "/livekit", + 1, + "ws://localhost:7880", + true, + ); + + // Let this attempt reach room.connect() -> restoreLocalVoiceState() -> + // setMicrophoneEnabled(), which is now stalled on micDeferred. + await vi.advanceTimersByTimeAsync(0); + expect((session as any)._state.type).toBe("connected"); + expect((session as any)._state.channelId).toBe(1); + + const leaveSpy = vi.spyOn(session, "leaveVoice"); + + // A newer attempt (channel 2) has already superseded this one and + // installed its own connected state into the shared _state. + (session as any)._state = { + type: "connected", + room: mockRoom, + channelId: 2, + latestToken: "token-B", + lastUrl: "/livekit-b", + lastDirectUrl: undefined, + }; + + // Now let the stalled attempt's restoreLocalVoiceState resolve. + micDeferred.resolve(undefined); + const result = await resultPromise; + + expect(result).toBe("superseded"); + // Must never call the global leaveVoice() here — that would tear down + // whatever the newer attempt just installed (worker, E2EE state, room). + expect(leaveSpy).not.toHaveBeenCalled(); + // The newer attempt's state must be untouched. + expect((session as any)._state.type).toBe("connected"); + expect((session as any)._state.channelId).toBe(2); + + leaveSpy.mockRestore(); + }); + }); + describe("handleVoiceToken pending join drain", () => { it("calls handleVoiceTokenRefresh when already connected to same channel", async () => { session.setServerHost("localhost:7880"); @@ -1872,6 +2317,35 @@ describe("LiveKitSession", () => { }); }); + describe("E2EE worker lifecycle", () => { + // The key provider lives for the whole process while livekit registers a + // new SetKey listener on it per Room — with no matching removal — and the + // per-room E2EE Worker is never terminated. Without explicit teardown, + // every join/switch/reconnect-attempt leaks a running worker that keeps + // receiving every future room key via setKey fan-out. + it("clears stale provider listeners and terminates the previous worker on createRoom", () => { + (session as any).createRoom(); + const workerMock = globalThis.Worker as unknown as ReturnType<typeof vi.fn>; + const worker1 = workerMock.mock.instances.at(-1) as unknown as { terminate: () => void }; + + (session as any).createRoom(); + + expect(worker1.terminate).toHaveBeenCalled(); + const provider = (session as any)._e2ee.keyProvider; + expect(provider.removeAllListeners).toHaveBeenCalled(); + }); + + it("terminates the current worker on leaveVoice so the last room key does not stay resident", () => { + (session as any).createRoom(); + const workerMock = globalThis.Worker as unknown as ReturnType<typeof vi.fn>; + const worker = workerMock.mock.instances.at(-1) as unknown as { terminate: () => void }; + + session.leaveVoice(false); + + expect(worker.terminate).toHaveBeenCalled(); + }); + }); + describe("attemptAutoReconnect (lifecycle)", () => { it("returns without reconnecting when signal is aborted during delay", async () => { (session as any)._state = { @@ -1956,6 +2430,42 @@ describe("LiveKitSession", () => { expect(mockRoom.connect).toHaveBeenCalledTimes(2); }); + it("disconnects the failed attempt's room instead of leaking it", async () => { + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; + session.setServerHost("localhost:7880"); + const ac = new AbortController(); + + mockRoom.connect + .mockRejectedValueOnce(new Error("first attempt failed")) + .mockResolvedValueOnce(undefined); + + const reconnectPromise = (session as any).attemptAutoReconnect( + "token", + "/livekit", + 5, + "ws://localhost:7880", + ac.signal, + ); + + await vi.advanceTimersByTimeAsync(3100); + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + // The room whose connect failed must be torn down — in "reconnecting" + // state this._room is null, so the cleanup must target the attempt's + // own room. A leaked room keeps its listeners and its synchronous + // Disconnected event spawns a second, uncancellable reconnect loop. + expect(mockRoom.removeAllListeners).toHaveBeenCalled(); + expect(mockRoom.disconnect).toHaveBeenCalledTimes(1); + }); + it("calls leaveVoice, leaveVoiceChannel, and error callback after all attempts fail", async () => { (session as any)._state = { type: "reconnecting", @@ -1988,6 +2498,121 @@ describe("LiveKitSession", () => { expect(errorCb).toHaveBeenCalledWith("Voice connection lost — failed to reconnect"); }); + // v004 regression: if the user leaves/switches channels while the FINAL + // reconnect attempt is in flight, the post-loop give-up cleanup must not + // fire — calling the global leaveVoice(true) there would tear down + // whatever live session replaced this stale reconnect loop (CLAUDE.md: + // voice sessions are superseded, not cancelled). + it("skips give-up cleanup when superseded before the final attempt resolves", async () => { + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; + session.setServerHost("localhost:7880"); + const errorCb = vi.fn(); + session.setOnError(errorCb); + const ac = new AbortController(); + + // Every attempt fails, and on the LAST attempt's failure the session + // has already moved on to a different (live) channel — simulating the + // user joining channel 9 while attempt 2 (MAX_RECONNECT_ATTEMPTS) was + // still connecting. + let connectCalls = 0; + mockRoom.connect.mockImplementation(() => { + connectCalls++; + if (connectCalls >= 2) { + (session as any)._state = { + type: "connected", + room: mockRoom, + channelId: 9, + latestToken: "other-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + }; + } + return Promise.reject(new Error("always fails")); + }); + + const reconnectPromise = (session as any).attemptAutoReconnect( + "token", + "/livekit", + 5, + "ws://localhost:7880", + ac.signal, + ); + + await vi.advanceTimersByTimeAsync(3100); + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + // The give-up path must not have run: no error toast, no leaveVoiceChannel, + // and channel 9's "connected" state must still be intact. + expect(errorCb).not.toHaveBeenCalledWith("Voice connection lost — failed to reconnect"); + expect(leaveVoiceChannel).not.toHaveBeenCalled(); + expect((session as any)._state.type).toBe("connected"); + expect((session as any)._state.channelId).toBe(9); + }); + + // v004, same-channel variant: re-joining the channel we are reconnecting + // to leaves `signal.aborted` false (connectAndSetup's entry-point + // leaveVoice(false) is skipped — the `_room` getter is null while + // "reconnecting") AND `_currentChannelId` equal to ours, so only the + // state-type check can tell the stale loop it no longer owns the session. + it("skips give-up cleanup when the same channel was re-joined by a newer session", async () => { + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; + session.setServerHost("localhost:7880"); + const errorCb = vi.fn(); + session.setOnError(errorCb); + const ac = new AbortController(); + + let connectCalls = 0; + mockRoom.connect.mockImplementation(() => { + connectCalls++; + if (connectCalls >= 2) { + // A fresh join for the SAME channel completed while the final + // reconnect attempt was in flight. + (session as any)._state = { + type: "connected", + room: mockRoom, + channelId: 5, + latestToken: "fresh-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + }; + } + return Promise.reject(new Error("always fails")); + }); + + const reconnectPromise = (session as any).attemptAutoReconnect( + "token", + "/livekit", + 5, + "ws://localhost:7880", + ac.signal, + ); + + await vi.advanceTimersByTimeAsync(3100); + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + expect(errorCb).not.toHaveBeenCalledWith("Voice connection lost — failed to reconnect"); + expect(leaveVoiceChannel).not.toHaveBeenCalled(); + expect((session as any)._state.type).toBe("connected"); + expect((session as any)._state.channelId).toBe(5); + expect((session as any)._state.latestToken).toBe("fresh-token"); + }); + it("catches room disconnect failure during cleanup without throwing", async () => { (session as any)._state = { type: "reconnecting", @@ -2144,7 +2769,7 @@ describe("LiveKitSession", () => { beforeEach(() => { // Restore TOFU mock defaults — persistent overrides survive clearAllMocks. - (getIdentityPin as any).mockResolvedValue(null); + (getIdentityPin as any).mockResolvedValue({ status: "unpinned" }); (storeIdentityPin as any).mockResolvedValue(true); (verifyEphemeralKeySignature as any).mockResolvedValue(true); }); @@ -2181,7 +2806,7 @@ describe("LiveKitSession", () => { it("pins the peer identity key on first sight and marks it verified", async () => { seedPeer("peer-identity-b64"); - (getIdentityPin as any).mockResolvedValue(null); + (getIdentityPin as any).mockResolvedValue({ status: "unpinned" }); const ws = { send: vi.fn() }; await joinAsKeyHolder(ws); ws.send.mockClear(); @@ -2203,7 +2828,7 @@ describe("LiveKitSession", () => { it("blocks and emits identity-tofu when the pinned identity key changed", async () => { seedPeer("new-identity-b64"); - (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + (getIdentityPin as any).mockResolvedValue({ status: "pinned", pin: "old-identity-b64" }); const ws = { send: vi.fn() }; await joinAsKeyHolder(ws); ws.send.mockClear(); @@ -2220,12 +2845,37 @@ describe("LiveKitSession", () => { expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); }); + it("fails closed when the pin store cannot be read (DC-08): rejects, never re-pins", async () => { + // A transient keyring error used to read as "no pin stored", sending the + // peer down the first-sight path — verifying against and RE-PINNING the + // server-delivered key. With the pin unknown, no trust decision is + // possible: reject the announce and surface the distinct "unknown" state. + seedPeer("peer-identity-b64"); + (getIdentityPin as any).mockResolvedValue({ status: "unavailable" }); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + (storeIdentityPin as any).mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "unknown", safetyNumber: null }), + ); + // Not treated as first sight: no signature check, no pin write, no key + // stored, no room-key offer. + expect(verifyEphemeralKeySignature).not.toHaveBeenCalled(); + expect(storeIdentityPin).not.toHaveBeenCalled(); + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); + expect(offerSends(ws)).toHaveLength(0); + }); + it("blocks a pinned peer when the server strips its published identity key", async () => { // Peer was pinned before; the server now omits identity_public_key to // shove the peer onto the legacy accept path (finding #2). A pinned peer // must never fall back to legacy — this is an identity mismatch. seedPeer(null); - (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + (getIdentityPin as any).mockResolvedValue({ status: "pinned", pin: "old-identity-b64" }); const ws = { send: vi.fn() }; await joinAsKeyHolder(ws); ws.send.mockClear(); @@ -2267,7 +2917,7 @@ describe("LiveKitSession", () => { // Peer legitimately rotated its identity key (reinstall / new device). // Its pinned key mismatches the new published one → blocked. seedPeer("new-identity-b64"); - (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + (getIdentityPin as any).mockResolvedValue({ status: "pinned", pin: "old-identity-b64" }); const ws = { send: vi.fn() }; await joinAsKeyHolder(ws); ws.send.mockClear(); @@ -2285,7 +2935,7 @@ describe("LiveKitSession", () => { expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "new-identity-b64"); // Store now holds the new pin; a fresh valid announce verifies. - (getIdentityPin as any).mockResolvedValue("new-identity-b64"); + (getIdentityPin as any).mockResolvedValue({ status: "pinned", pin: "new-identity-b64" }); (storeIdentityPin as any).mockClear(); ws.send.mockClear(); await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); diff --git a/Client/tauri-client/tests/unit/login-form-totp-reentrancy.test.ts b/Client/tauri-client/tests/unit/login-form-totp-reentrancy.test.ts new file mode 100644 index 00000000..a0b0aa33 --- /dev/null +++ b/Client/tauri-client/tests/unit/login-form-totp-reentrancy.test.ts @@ -0,0 +1,80 @@ +// Regression test for finding v081: handleTotpSubmit (LoginForm.ts) had no +// in-flight guard, so Enter-key auto-repeat (or a fast double-Enter) during +// the verify round trip could fire a second onTotpSubmit call before the +// first resolved. Covered here through ConnectPage, which mounts LoginForm. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createConnectPage } from "../../src/pages/ConnectPage"; +import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage"; + +vi.mock("../../src/lib/credentials", () => ({ + loadCredential: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../src/components/SettingsOverlay", () => ({ + createSettingsOverlay: () => ({ + mount: vi.fn(), + destroy: vi.fn(), + }), +})); + +function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): ConnectPageCallbacks { + return { + onLogin: vi.fn().mockResolvedValue(undefined), + onRegister: vi.fn().mockResolvedValue(undefined), + onTotpSubmit: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +const testProfiles: SimpleProfile[] = [{ name: "Test Server", host: "localhost:8443" }]; + +describe("LoginForm TOTP re-entrancy guard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("ignores a second Enter press fired before the first verify call resolves", async () => { + let resolveVerify: (() => void) | undefined; + const onTotpSubmit = vi.fn( + () => + new Promise<void>((resolve) => { + resolveVerify = resolve; + }), + ); + const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles); + page.mount(container); + page.showTotp(); + + const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement; + totpInput.value = "654321"; + + // First Enter starts the in-flight request (disables the submit button). + totpInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await vi.waitFor(() => expect(onTotpSubmit).toHaveBeenCalledTimes(1)); + + // Auto-repeat / a second Enter while still verifying must not fire again. + totpInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + totpInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(onTotpSubmit).toHaveBeenCalledTimes(1); + + resolveVerify?.(); + await vi.waitFor(() => { + // Button re-enabled once the in-flight request resolves. + const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement; + expect(verifyBtn.disabled).toBe(false); + }); + + // Now that it resolved, a fresh Enter is allowed to submit again. + totpInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await vi.waitFor(() => expect(onTotpSubmit).toHaveBeenCalledTimes(2)); + + page.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/main-page.test.ts b/Client/tauri-client/tests/unit/main-page.test.ts new file mode 100644 index 00000000..6c5c0557 --- /dev/null +++ b/Client/tauri-client/tests/unit/main-page.test.ts @@ -0,0 +1,279 @@ +/** + * MainPage's activeChannelId subscriber (finding: a deleted/closed active + * channel left its message list and composer mounted, because the + * subscriber had no else branch to tear them down). + * + * MainPage.ts is excluded from unit coverage (vitest.config.ts) — it has + * lots of heavy child components (SidebarArea, ChatArea, ChannelController) + * that are extracted specifically to be independently testable, so those are + * mocked out here and only the wiring under test (the store subscription) is + * exercised for real. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/livekitSession", () => ({ + cleanupAll: vi.fn(), + setOnRemoteVideo: vi.fn(), + setOnRemoteVideoRemoved: vi.fn(), + clearOnRemoteVideo: vi.fn(), + setWsClient: vi.fn(), + setServerHost: vi.fn(), + setOnError: vi.fn(), + leaveVoice: vi.fn(), + setMuted: vi.fn(), + setDeafened: vi.fn(), + enableCamera: vi.fn().mockResolvedValue(undefined), + disableCamera: vi.fn().mockResolvedValue(undefined), + enableScreenshare: vi.fn().mockResolvedValue(undefined), + disableScreenshare: vi.fn().mockResolvedValue(undefined), + getLocalCameraStream: vi.fn(() => null), + getLocalScreenshareStream: vi.fn(() => null), +})); + +vi.mock("@lib/notifications", () => ({ + startRingChime: vi.fn(), + stopRingChime: vi.fn(), +})); + +vi.mock("@lib/autoIdle", () => ({ + startAutoIdle: vi.fn(() => ({ destroy: vi.fn() })), +})); + +const { + mockMountChannel, + mockDestroyChannel, + mockCreateChannelController, + mockCreateSidebarArea, + mockCreateChatArea, +} = vi.hoisted(() => ({ + mockMountChannel: vi.fn(), + mockDestroyChannel: vi.fn(), + mockCreateChannelController: vi.fn(), + mockCreateSidebarArea: vi.fn(), + mockCreateChatArea: vi.fn(), +})); + +vi.mock("../../src/pages/main-page/ChannelController", () => ({ + createChannelController: (...args: unknown[]) => { + mockCreateChannelController(...args); + return { + mountChannel: mockMountChannel, + destroyChannel: mockDestroyChannel, + openFilePicker: vi.fn(), + currentChannelId: 0, + messageList: null, + }; + }, +})); + +vi.mock("../../src/pages/main-page/SidebarArea", () => ({ + createSidebarArea: (...args: unknown[]) => { + mockCreateSidebarArea(...args); + return { + sidebarWrapper: document.createElement("div"), + children: [], + unsubscribers: [], + openQuickSwitch: vi.fn(), + }; + }, +})); + +vi.mock("../../src/pages/main-page/ChatArea", () => ({ + createChatArea: (...args: unknown[]) => { + mockCreateChatArea(...args); + return { + chatArea: document.createElement("div"), + slots: { + messagesSlot: document.createElement("div"), + typingSlot: document.createElement("div"), + inputSlot: document.createElement("div"), + videoGridSlot: document.createElement("div"), + }, + videoGrid: { + addStream: vi.fn(), + removeStream: vi.fn(), + hasStreams: vi.fn(() => false), + setFocusedTile: vi.fn(), + getFocusedTileId: vi.fn(() => null), + mount: vi.fn(), + destroy: vi.fn(), + }, + chatHeaderName: document.createElement("span"), + chatHeaderRefs: { + hashEl: document.createElement("span"), + nameEl: document.createElement("span"), + topicEl: document.createElement("span"), + callBtn: document.createElement("button"), + }, + searchCtrl: { open: vi.fn(), cleanup: vi.fn() }, + dmProfileSlot: document.createElement("div"), + children: [], + unsubscribers: [], + }; + }, +})); + +import { createMainPage } from "../../src/pages/MainPage"; +import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store"; +import { authStore } from "../../src/stores/auth.store"; +import { uiStore } from "../../src/stores/ui.store"; +import { voiceStore } from "../../src/stores/voice.store"; +import { dmStore } from "../../src/stores/dm.store"; +import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws"; +import type { ApiClient } from "../../src/lib/api"; +import type { ServerMessage } from "../../src/lib/types"; + +function resetStores(): void { + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "alice", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected", settingsOpen: false })); + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + joinedAt: null, + listenOnly: false, + voiceStatus: "idle", + })); + dmStore.setState(() => ({ channels: [] })); +} + +function fakeWs(): WsClient { + const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); + return { + connect: vi.fn(), + disconnect: vi.fn(), + send: vi.fn(() => "id"), + on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener as unknown as WsListener<ServerMessage["type"]>); + return () => { + listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>); + }; + }, + onStateChange: vi.fn(() => () => {}), + onSendFailure: vi.fn(() => () => {}), + onCertMismatch: vi.fn(() => () => {}), + onCertFirstUse: vi.fn(() => () => {}), + startCertListener: vi.fn(async () => {}), + acceptCertFingerprint: vi.fn(async () => {}), + getState: vi.fn(() => "connected" as ConnectionState), + isReplaying: vi.fn(() => false), + _getWs: vi.fn(() => null), + }; +} + +function fakeApi(): ApiClient { + return { + getConfig: () => ({ host: "" }), + getReactionUsers: vi.fn(async () => ({ users: [] })), + } as unknown as ApiClient; +} + +function textChannel(id: number, name: string, position = 0) { + return { + id, + name, + type: "text" as const, + category: null, + position, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }; +} + +describe("MainPage — activeChannelId subscriber", () => { + let container: HTMLDivElement; + let page: ReturnType<typeof createMainPage>; + + beforeEach(() => { + resetStores(); + mockMountChannel.mockClear(); + mockDestroyChannel.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + page?.destroy?.(); + container.remove(); + }); + + it("mounts the channel when an active channel is set", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, textChannel(1, "general")); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + + page = createMainPage({ ws: fakeWs(), api: fakeApi() }); + page.mount(container); + + expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text"); + }); + + it("destroys the mounted channel when the active channel is cleared (deleted/closed while offline)", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, textChannel(1, "general")); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + + page = createMainPage({ ws: fakeWs(), api: fakeApi() }); + page.mount(container); + expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text"); + + // The channel is gone and nothing replaces it as active — this is what + // dispatcher.ts's ready handler now does when the previously-active + // channel is absent from a fresh snapshot. + setActiveChannel(null); + channelsStore.flush(); + + expect(mockDestroyChannel).toHaveBeenCalledOnce(); + }); + + it("mounts the newly active channel when switching between two channels", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(1, textChannel(1, "general")); + ch.set(2, textChannel(2, "random", 1)); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + + page = createMainPage({ ws: fakeWs(), api: fakeApi() }); + page.mount(container); + + setActiveChannel(2); + channelsStore.flush(); + + expect(mockMountChannel).toHaveBeenCalledWith(2, "random", "text"); + // Switching to a real channel remounts rather than destroying. + expect(mockDestroyChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/mention-autocomplete.test.ts b/Client/tauri-client/tests/unit/mention-autocomplete.test.ts index 0f2d11e2..125aed42 100644 --- a/Client/tauri-client/tests/unit/mention-autocomplete.test.ts +++ b/Client/tauri-client/tests/unit/mention-autocomplete.test.ts @@ -219,6 +219,85 @@ describe("createMentionAutocomplete", () => { popup.destroy(); expect(popup.element.parentNode).toBeNull(); }); + + it("stamps a stable id on the listbox and index ids on the rows", () => { + popup.setQuery("al"); + expect(popup.element.id).toBe("mention-autocomplete"); + const ids = Array.from(popup.element.querySelectorAll(".ma-item")).map((r) => r.id); + expect(ids).toEqual(["mention-autocomplete-option-0", "mention-autocomplete-option-1"]); + // A re-render rebuilds the rows, so the ids stay index-based, not stale. + popup.setQuery("bo"); + expect(popup.element.querySelector(".ma-item")?.id).toBe("mention-autocomplete-option-0"); + }); +}); + +describe("createMentionAutocomplete combobox wiring", () => { + let ta: HTMLTextAreaElement; + let popup: ReturnType<typeof createMentionAutocomplete>; + + beforeEach(() => { + ta = document.createElement("textarea"); + document.body.appendChild(ta); + popup = createMentionAutocomplete({ + onSelect: vi.fn(), + onClose: vi.fn(), + comboboxInput: ta, + }); + document.body.appendChild(popup.element); + }); + + afterEach(() => { + popup.destroy(); + ta.remove(); + }); + + function key(k: string): KeyboardEvent { + return new KeyboardEvent("keydown", { key: k, cancelable: true }); + } + + it("stamps combobox semantics on the input while open", () => { + expect(ta.getAttribute("role")).toBe("combobox"); + expect(ta.getAttribute("aria-autocomplete")).toBe("list"); + expect(ta.getAttribute("aria-expanded")).toBe("true"); + expect(ta.getAttribute("aria-controls")).toBe("mention-autocomplete"); + }); + + it("aims aria-activedescendant at the active row and follows the arrows", () => { + popup.setQuery("al"); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-0"); + popup.handleKeydown(key("ArrowDown")); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-1"); + popup.handleKeydown(key("ArrowUp")); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-0"); + }); + + it("keeps DOM focus out of the list — activedescendant is the only focus", () => { + ta.focus(); + popup.setQuery("al"); + popup.handleKeydown(key("ArrowDown")); + expect(document.activeElement).toBe(ta); + expect(popup.element.querySelector("[tabindex]")).toBeNull(); + }); + + it("clears aria-activedescendant when nothing matches", () => { + popup.setQuery("al"); + popup.setQuery("zzz"); + expect(ta.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("removes every combobox attribute on destroy", () => { + popup.setQuery("al"); + popup.destroy(); + for (const attr of [ + "role", + "aria-autocomplete", + "aria-expanded", + "aria-controls", + "aria-activedescendant", + ]) { + expect(ta.hasAttribute(attr)).toBe(false); + } + }); }); describe("composer integration", () => { @@ -334,4 +413,81 @@ describe("composer integration", () => { textarea().dispatchEvent(new FocusEvent("blur")); expect(popupEl()).toBeNull(); }); + + it("resyncs on a keyboard caret move, so Home+Enter sends instead of splicing a stale completion", () => { + type("@ali"); + expect(popupEl()).not.toBeNull(); + + // Home jumps the caret to the start of the line — the popup must notice + // the caret left the token, the same way it does for a mouse click. + const ta = textarea(); + ta.selectionStart = 0; + ta.selectionEnd = 0; + ta.dispatchEvent(new KeyboardEvent("keyup", { key: "Home", bubbles: true })); + + expect(popupEl()).toBeNull(); + press("Enter"); + expect(onSend).toHaveBeenCalledWith("@ali", null, []); + }); + + // The caret-move resync must skip the keys the popup owns: a real browser + // always fires keyup after keydown, so resyncing on Escape's keyup would + // reopen the popup the keydown just dismissed. + it("stays closed through the keyup that follows Escape", () => { + type("hey @al"); + press("Escape"); + textarea().dispatchEvent(new KeyboardEvent("keyup", { key: "Escape", bubbles: true })); + + expect(popupEl()).toBeNull(); + press("Enter"); + expect(onSend).toHaveBeenCalledWith("hey @al", null, []); + }); + + // Likewise for the arrows: setQuery resets the highlight to row 0, so a + // resync on ArrowDown's keyup would make the popup unnavigable. + it("keeps the arrow-key highlight through the keyup that follows the keydown", () => { + type("@al"); + const ta = textarea(); + press("ArrowDown"); + ta.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowDown", bubbles: true })); + + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-1"); + press("Enter"); + expect(ta.value).toBe("@alice "); + }); + + // Backstop for caret moves the composer never sees at all (Ctrl+A leaves no + // input event and no caret-move keyup): completing there used to splice the + // token at a stale anchor, producing "@alice @ali". + it("refuses to complete when the caret no longer follows the token", () => { + type("@ali"); + const ta = textarea(); + ta.selectionStart = 0; + ta.selectionEnd = ta.value.length; + + press("Enter"); + expect(ta.value).toBe("@ali"); + expect(popupEl()).toBeNull(); + }); + + it("marks the textarea as a combobox while open and follows the arrows", () => { + type("@al"); + const ta = textarea(); + expect(ta.getAttribute("role")).toBe("combobox"); + expect(ta.getAttribute("aria-expanded")).toBe("true"); + expect(ta.getAttribute("aria-controls")).toBe("mention-autocomplete"); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-0"); + press("ArrowDown"); + expect(ta.getAttribute("aria-activedescendant")).toBe("mention-autocomplete-option-1"); + }); + + it("drops the combobox state when the popup closes", () => { + type("@al"); + press("Escape"); + const ta = textarea(); + expect(ta.hasAttribute("role")).toBe(false); + expect(ta.hasAttribute("aria-expanded")).toBe(false); + expect(ta.hasAttribute("aria-controls")).toBe(false); + expect(ta.hasAttribute("aria-activedescendant")).toBe(false); + }); }); diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 010b87af..057bdaa9 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -426,6 +426,22 @@ describe("MessageInput", () => { comp.destroy?.(); }); + it("file picker accept attribute only advertises extensions the MIME allowlist accepts", () => { + const opts = makeOptions({ + onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })), + }); + const comp = createMessageInput(opts); + comp.mount(container); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + // .rar/.7z were advertised here but rejected by ALLOWED_TYPES on pick — + // an always-rejected picker option. + expect(fileInput.accept).not.toContain(".rar"); + expect(fileInput.accept).not.toContain(".7z"); + + comp.destroy?.(); + }); + it("file upload shows preview and sends attachment ID with message", async () => { const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" }; const onUploadFile = vi.fn(async () => uploadResult); @@ -617,6 +633,45 @@ describe("MessageInput", () => { comp.destroy?.(); }); + it("remove button removes attachment preview after the upload has completed", async () => { + const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" }; + const onUploadFile = vi.fn(async () => uploadResult); + const opts = makeOptions({ onUploadFile }); + const comp = createMessageInput(opts); + comp.mount(container); + + const testFile = new File(["image data"], "test.png", { type: "image/png" }); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(fileInput, "files", { value: [testFile], writable: true }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + + // Wait for the upload to resolve — the entry's id is now the server id, + // not the tempId the remove button was created with. + await vi.waitFor(() => { + expect(onUploadFile).toHaveBeenCalledWith(testFile); + }); + const previewBar = container.querySelector(".attachment-preview-bar"); + await vi.waitFor(() => { + expect(previewBar!.querySelector(".uploading")).toBeNull(); + }); + + const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement; + expect(removeBtn).not.toBeNull(); + removeBtn.click(); + + expect(previewBar!.classList.contains("visible")).toBe(false); + expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull(); + + // Sending now must not include the removed attachment. + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "no attachment"; + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledWith("no attachment", null, []); + + comp.destroy?.(); + }); + // ── setReplyTo clears edit mode ── it("setReplyTo hides edit bar if editing", () => { @@ -770,6 +825,28 @@ describe("MessageInput", () => { comp.destroy?.(); }); + it("selecting a GIF sends it without discarding a typed draft", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "wait for it..."; + + const gifBtn = container.querySelector(".gif-btn") as HTMLElement; + gifBtn.click(); + expect(lastGifPickerOptions).not.toBeNull(); + lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif"); + + // The GIF is sent as its own message... + expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []); + // ...and the user's typed draft survives, instead of being overwritten + // and thrown away. + expect(textarea.value).toBe("wait for it..."); + + comp.destroy?.(); + }); + it("opening GIF picker closes emoji picker", () => { const opts = makeOptions(); const comp = createMessageInput(opts); @@ -1105,5 +1182,17 @@ describe("MessageInput", () => { it("does not mistake a short selection for a wrapped one", () => { expect(wrapWithMarker("**", 0, 2, "**").value).toBe("******"); }); + + it("wraps rather than mangles a selection spanning multiple already-wrapped spans", () => { + // The selection starts and ends with "*" but is not itself a single + // wrapped span — unwrapping it would destroy both interior spans. + const result = wrapWithMarker("*hello* world *bye*", 0, 19, "*"); + expect(result.value).toBe("**hello* world *bye**"); + }); + + it("wraps rather than downgrades bold text when italicizing", () => { + const result = wrapWithMarker("**bold**", 0, 8, "*"); + expect(result.value).toBe("***bold***"); + }); }); }); diff --git a/Client/tauri-client/tests/unit/message-jump.test.ts b/Client/tauri-client/tests/unit/message-jump.test.ts index 843cb589..402d1184 100644 --- a/Client/tauri-client/tests/unit/message-jump.test.ts +++ b/Client/tauri-client/tests/unit/message-jump.test.ts @@ -305,6 +305,57 @@ describe("createMessageJumper", () => { await expect(jumper.jumpTo(1, 42)).resolves.toBe(false); }); + + it("a stale jump response does not overwrite a newer jump's window (race guard)", async () => { + // Neither jump ever finds the target already loaded, so both go through + // the fetch path. + const scrollToMessage = vi.fn().mockReturnValue(false); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType<typeof fakeCtrl>["ctrl"]; + + let resolveA: (v: unknown) => void = () => {}; + let resolveB: (v: unknown) => void = () => {}; + const getMessagesAround = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveA = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveB = resolve))); + + const jumper = createMessageJumper({ + api: fakeApi(getMessagesAround), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + // A (older, target 10) starts and suspends on its fetch; B (newer, + // target 20) starts right after — both requests are now in flight. + const jumpA = jumper.jumpTo(1, 10); + const jumpB = jumper.jumpTo(1, 20); + expect(getMessagesAround).toHaveBeenCalledTimes(2); + + // B's response lands first (real network reordering). + resolveB({ messages: [response(20)], has_more_before: true, has_more_after: true }); + await jumpB; + expect( + messagesStore + .getState() + .messagesByChannel.get(1) + ?.map((m) => m.id), + ).toEqual([20]); + + // A's response lands after B already applied its window — the stale + // response must not clobber it. + resolveA({ messages: [response(10)], has_more_before: true, has_more_after: true }); + await jumpA; + + expect( + messagesStore + .getState() + .messagesByChannel.get(1) + ?.map((m) => m.id), + ).toEqual([20]); + }); }); // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/message-list-new-divider.test.ts b/Client/tauri-client/tests/unit/message-list-new-divider.test.ts index 8de01a33..e4792f2d 100644 --- a/Client/tauri-client/tests/unit/message-list-new-divider.test.ts +++ b/Client/tauri-client/tests/unit/message-list-new-divider.test.ts @@ -15,7 +15,7 @@ if (typeof globalThis.ResizeObserver === "undefined") { import { createMessageList } from "@components/MessageList"; import type { MessageListOptions } from "@components/MessageList"; -import { messagesStore } from "@stores/messages.store"; +import { messagesStore, addMessage } from "@stores/messages.store"; import { membersStore } from "@stores/members.store"; import type { Message } from "@stores/messages.store"; import { channelsStore, setChannels, setActiveChannel } from "@stores/channels.store"; @@ -208,6 +208,58 @@ describe("MessageList — new-messages divider", () => { expect(container.querySelector('[data-testid="new-messages-divider"]')).toBeNull(); }); + // Regression: firstUnreadIndex is messages.length - unreadOnOpen, an offset + // from the end. A full rebuild after messages arrive during the visit used + // to recompute that offset against the new (longer) length, sliding the + // line down past the messages it was placed to mark. + it("keeps the divider anchored to the same message across a rebuild after messages arrive", () => { + setMessages([1, 2, 3, 4, 5].map(makeMessage)); + openChannelWithUnread(2); + mount(); + + const before = container.querySelector('[data-testid="new-messages-divider"]') + ?.nextElementSibling as HTMLElement; + expect(before.dataset.testid).toBe("message-4"); + + // Three more messages arrive while the reader is on the channel, via the + // real append path (addMessage), which preserves the existing rows' + // identity — the append fast path handles this correctly on its own. + // Store notifications are microtask-batched, so every step is flushed: + // without that the assertions below read the DOM from mount() and pass + // no matter what rebuildItems would have done. + for (const id of [6, 7, 8]) { + addMessage({ + id, + channel_id: CHANNEL_ID, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${id}`, + reply_to: null, + attachments: [], + timestamp: new Date(Date.UTC(2024, 0, 15, 12, id * 5)).toISOString(), + }); + } + messagesStore.flush(); + expect(container.querySelector('[data-testid="message-8"]')).not.toBeNull(); + const afterAppend = container.querySelector('[data-testid="new-messages-divider"]') + ?.nextElementSibling as HTMLElement; + expect(afterAppend.dataset.testid).toBe("message-4"); + + // A non-append change (an edit) forces a full rebuild instead of the + // append fast path — this is where the count-based offset used to drift. + messagesStore.setState((prev) => { + const list = prev.messagesByChannel.get(CHANNEL_ID)!; + const next = list.map((m) => (m.id === 1 ? { ...m, content: "edited" } : m)); + const updated = new Map(prev.messagesByChannel); + updated.set(CHANNEL_ID, next); + return { ...prev, messagesByChannel: updated }; + }); + messagesStore.flush(); + + const after = container.querySelector('[data-testid="new-messages-divider"]') + ?.nextElementSibling as HTMLElement; + expect(after.dataset.testid).toBe("message-4"); + }); + // The line marks a boundary; the message under it must not be rendered as a // grouped continuation of the message above the line. it("breaks message grouping at the divider", () => { diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 9e361a2f..1dbf0455 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -357,6 +357,48 @@ describe("MessageList", () => { expect(options.onScrollTop).toHaveBeenCalledTimes(2); }); + it("clears loadingOlder once the onScrollTop promise settles, even when no new messages arrived (failed fetch)", async () => { + setHasMore(1, true); + setMessages(1, [makeMessage({ id: 1 })]); + // Flush this setup notification now — store notifications are deferred to + // a microtask, and without this it would land during the first `await` + // below and reset loadingOlder for an unrelated reason (prevMessageCount + // syncing from its initial 0), masking the bug this test targets. + messagesStore.flush(); + let resolveLoad: () => void = () => {}; + const onScrollTop = vi.fn( + () => + new Promise<void>((resolve) => { + resolveLoad = resolve; + }), + ); + // Recreate with the overriding onScrollTop — it's declared readonly, so + // it must be set at construction rather than mutated on the shared + // `options` object from beforeEach. + msgList = createMessageList({ ...options, onScrollTop }); + msgList.mount(container); + + const root = container.querySelector(".messages-container") as HTMLDivElement; + root.dispatchEvent(new Event("scroll")); + expect(onScrollTop).toHaveBeenCalledTimes(1); + + // A second scroll-to-top while the fetch is still in flight must not + // re-trigger it. + root.dispatchEvent(new Event("scroll")); + expect(onScrollTop).toHaveBeenCalledTimes(1); + + // The fetch settles WITHOUT any new messages arriving — the failure path + // (a real onScrollTop catches its own error and never rejects, so the + // promise resolves either way; the store just never changed). + resolveLoad(); + await Promise.resolve(); + await Promise.resolve(); + + // loadingOlder must now be false — scrolling to top again re-triggers it. + root.dispatchEvent(new Event("scroll")); + expect(onScrollTop).toHaveBeenCalledTimes(2); + }); + it("scrollToMessage returns false before mount", () => { // scrollToMessage should be safe to call before mount const unmounted = createMessageList(options); diff --git a/Client/tauri-client/tests/unit/messages-store-detached.test.ts b/Client/tauri-client/tests/unit/messages-store-detached.test.ts index 4d62471b..09acf7a7 100644 --- a/Client/tauri-client/tests/unit/messages-store-detached.test.ts +++ b/Client/tauri-client/tests/unit/messages-store-detached.test.ts @@ -215,6 +215,30 @@ describe("reattaching via a fresh tail fetch", () => { }); }); +describe("prependMessages at the message cap", () => { + it("keeps the fetched older page and detaches instead of discarding it", () => { + // Fill to the 500-row cap: ids 101..600 (history endpoint is newest-first). + const initial: MessageResponse[] = []; + for (let id = 600; id >= 101; id--) initial.push(response(id)); + setMessages(1, initial, true); + expect(getChannelMessages(1)).toHaveLength(500); + + prependMessages(1, [response(100), response(99)], false); + + const loaded = getChannelMessages(1); + expect(loaded).toHaveLength(500); + // The fetched page must survive at the head — trimming it away would make + // every scroll-up fetch at the cap a silent no-op that refetches forever. + expect(loaded[0]!.id).toBe(99); + expect(loaded[1]!.id).toBe(100); + // The live tail was dropped instead, so the window is detached and the + // "Jump to Present" pill restores it. + expect(isWindowDetached(1)).toBe(true); + // Nothing above was dropped, so "more above" is what the server said. + expect(hasMoreMessages(1)).toBe(false); + }); +}); + describe("hasMessageLoaded", () => { it("reports membership of the loaded window", () => { setAroundMessages(1, ascendingWindow(10, 12), true, true); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index 18076891..a8dc3e93 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -9,6 +9,8 @@ import { bulkDeleteMessages, setMessagePinned, updateReaction, + addOptimisticReaction, + rollbackReaction, addPendingSend, confirmSend, addOptimisticMessage, @@ -17,6 +19,7 @@ import { getChannelMessages, isChannelLoaded, hasMoreMessages, + isWindowDetached, clearChannelMessages, setChannelLoading, setChannelLoadError, @@ -231,6 +234,45 @@ describe("messages store", () => { expect(msgs).toHaveLength(1); expect(msgs[0]!.id).toBe(20); }); + + it("keeps a newer live broadcast that landed while the history fetch was in flight", () => { + // The broadcast arrives over the open WS after the server ran the GET + // query but before the response reaches the client. + addMessage(makeChatPayload({ id: 300, channel_id: 1, content: "live" })); + + setMessages(1, [makeMessageResponse({ id: 201 }), makeMessageResponse({ id: 200 })], false); + + const msgs = getChannelMessages(1); + expect(msgs.map((m) => m.id)).toEqual([200, 201, 300]); + expect(msgs[2]!.content).toBe("live"); + }); + + it("keeps pending and failed optimistic rows across setMessages", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "in flight", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + addOptimisticMessage({ + correlationId: "c2", + channelId: 1, + user: TEST_USER, + content: "refused", + replyTo: null, + timestamp: "2026-03-15T10:00:01Z", + }); + markSendFailed("c2", "SLOW_MODE"); + + setMessages(1, [makeMessageResponse({ id: 200 })], false); + + const msgs = getChannelMessages(1); + expect(msgs.map((m) => m.correlationId)).toEqual([null, "c1", "c2"]); + expect(msgs[1]!.status).toBe("pending"); + expect(msgs[2]!.status).toBe("failed"); + }); }); // 4. prependMessages prepends older messages @@ -798,6 +840,99 @@ describe("messages store", () => { }); }); + // 13b. optimistic reactions (ux/messaging §5) + describe("optimistic reactions", () => { + const toggle = (action: "add" | "remove") => ({ + channelId: 1, + messageId: 100, + emoji: "👍", + action, + }); + + it("applies an optimistic add immediately as the current user's pill", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + + addOptimisticReaction("corr-1", toggle("add")); + + const msg = getChannelMessages(1)[0]!; + expect(msg.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]); + }); + + it("consumes the self-echo instead of double-counting it", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + addOptimisticReaction("corr-1", toggle("add")); + + // The server broadcasts the toggle back to its sender too. + updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1); + + const msg = getChannelMessages(1)[0]!; + expect(msg.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]); + expect(messagesStore.getState().pendingReactions?.size).toBe(0); + }); + + it("still applies another user's identical reaction while one is pending", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + addOptimisticReaction("corr-1", toggle("add")); + + updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1); + + const msg = getChannelMessages(1)[0]!; + expect(msg.reactions).toEqual([{ emoji: "👍", count: 2, me: true }]); + // The pending toggle is NOT consumed by someone else's echo. + expect(messagesStore.getState().pendingReactions?.size).toBe(1); + }); + + it("rolls back a failed optimistic add (pill disappears)", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + addOptimisticReaction("corr-1", toggle("add")); + + expect(rollbackReaction("corr-1")).toBe(true); + + const msg = getChannelMessages(1)[0]!; + expect(msg.reactions).toHaveLength(0); + expect(messagesStore.getState().pendingReactions?.size).toBe(0); + }); + + it("rolls back a failed optimistic remove (pill restored)", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + // Someone else's reaction plus mine. + updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1); + updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1); + + addOptimisticReaction("corr-1", toggle("remove")); + expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 1, me: false }]); + + expect(rollbackReaction("corr-1")).toBe(true); + + expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 2, me: true }]); + }); + + it("rollback of an unknown correlation id reports false and changes nothing", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + const before = messagesStore.getState(); + + expect(rollbackReaction("nope")).toBe(false); + + expect(messagesStore.getState()).toBe(before); + }); + + it("a late error after the echo was consumed cannot roll back (no ghost revert)", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + addOptimisticReaction("corr-1", toggle("add")); + updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1); + + expect(rollbackReaction("corr-1")).toBe(false); + + expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]); + }); + + it("does not register a pending toggle for an unloaded channel", () => { + addOptimisticReaction("corr-1", { channelId: 99, messageId: 1, emoji: "👍", action: "add" }); + + expect(messagesStore.getState().pendingReactions?.size).toBe(0); + }); + }); + // 14. addMessage eviction beyond MAX_MESSAGES_PER_CHANNEL describe("addMessage eviction", () => { it("evicts oldest messages when exceeding cap (500)", () => { @@ -869,7 +1004,10 @@ describe("messages store", () => { expect(msgs).toHaveLength(500); }); - it("sets hasMore to true when trimming on prepend", () => { + it("keeps the server's hasMore and detaches when trimming on prepend", () => { + // Trimming on prepend drops the live tail (rows BELOW the window), not + // older history, so "more above" stays whatever the server said and the + // channel becomes a detached window instead. const initial: MessageResponse[] = []; for (let i = 301; i <= 500; i++) { initial.push(makeMessageResponse({ id: i, channel_id: 1 })); @@ -882,7 +1020,8 @@ describe("messages store", () => { } prependMessages(1, older, false); - expect(hasMoreMessages(1)).toBe(true); + expect(hasMoreMessages(1)).toBe(false); + expect(isWindowDetached(1)).toBe(true); }); }); @@ -996,12 +1135,51 @@ describe("messages store", () => { expect(messagesStore.getState().pendingSends.has("c1")).toBe(false); }); + it("removeOptimistic drops an already-failed row, whose channel is no longer in pendingSends", () => { + // markSendFailed deletes the correlationId from pendingSends when it + // flips the row to "failed" — the Retry/Delete buttons only render for + // failed rows, so this is the path every UI-driven removeOptimistic call + // actually takes. + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + markSendFailed("c1", "SLOW_MODE"); + expect(messagesStore.getState().pendingSends.has("c1")).toBe(false); + + removeOptimistic("c1"); + expect(getChannelMessages(1)).toHaveLength(0); + }); + it("addMessage is idempotent by real id (replay-safe)", () => { addMessage(makeChatPayload({ id: 700, content: "once" })); addMessage(makeChatPayload({ id: 700, content: "once" })); expect(getChannelMessages(1)).toHaveLength(1); }); + it("does not consume a pending row for a same-author broadcast with different content", () => { + addOptimisticMessage({ + correlationId: "c1", + channelId: 1, + user: TEST_USER, + content: "mine, still sending", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + // Same author, different content: a replayed message from another + // session of this account, not the echo of the pending send. + addMessage(makeChatPayload({ id: 800, user: TEST_USER, content: "from my other device" })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("pending"); + expect(msgs.find((m) => m.id === 800)!.content).toBe("from my other device"); + }); + it("defensively reconciles a broadcast that raced ahead of its ack", () => { addOptimisticMessage({ correlationId: "c1", diff --git a/Client/tauri-client/tests/unit/modal-factory.test.ts b/Client/tauri-client/tests/unit/modal-factory.test.ts index 8ea171d4..4f561f60 100644 --- a/Client/tauri-client/tests/unit/modal-factory.test.ts +++ b/Client/tauri-client/tests/unit/modal-factory.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createModal } from "../../src/lib/modalFactory"; +import { createModal, createPromptModal } from "../../src/lib/modalFactory"; describe("createModal", () => { let container: HTMLDivElement; @@ -142,4 +142,202 @@ describe("createModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + it("external abort removes the modal and fires onClose exactly once", () => { + const onClose = vi.fn(); + const externalAc = new AbortController(); + const content = document.createElement("div"); + const inst = createModal({ content, onClose, signal: externalAc.signal }, container); + + externalAc.abort(); + + expect(container.contains(inst.overlay)).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + + // A close() after the abort must not re-fire onClose or throw. + inst.close(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // ── dialog accessibility contract (DC-13) ───────────────────────────────── + + it("stamps dialog semantics on the modal container", () => { + const content = document.createElement("div"); + const inst = createModal({ content, ariaLabel: "Pick members" }, container); + + expect(inst.modal.getAttribute("role")).toBe("dialog"); + expect(inst.modal.getAttribute("aria-modal")).toBe("true"); + expect(inst.modal.getAttribute("aria-label")).toBe("Pick members"); + inst.destroy(); + }); + + it("moves focus into the dialog on open and restores it on close", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const content = document.createElement("div"); + const btn = document.createElement("button"); + content.appendChild(btn); + const inst = createModal({ content }, container); + + expect(document.activeElement).toBe(btn); + + inst.close(); + expect(document.activeElement).toBe(trigger); + }); + + it("restores focus when torn down by the external signal", () => { + const trigger = document.createElement("button"); + container.appendChild(trigger); + trigger.focus(); + + const externalAc = new AbortController(); + const inst = createModal( + { content: document.createElement("div"), signal: externalAc.signal }, + container, + ); + expect(document.activeElement).toBe(inst.modal); + + externalAc.abort(); + expect(document.activeElement).toBe(trigger); + }); + + it("traps Tab inside the dialog (wraps last → first)", () => { + const content = document.createElement("div"); + const first = document.createElement("button"); + const last = document.createElement("button"); + content.appendChild(first); + content.appendChild(last); + const inst = createModal({ content }, container); + + last.focus(); + const e = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); + last.dispatchEvent(e); + + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(first); + inst.destroy(); + }); +}); + +describe("createPromptModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll(".modal-overlay").forEach((el) => el.remove()); + }); + + function getInput(): HTMLInputElement { + const el = document.querySelector<HTMLInputElement>("[data-testid='prompt-input']"); + if (el === null) throw new Error("prompt input not rendered"); + return el; + } + + function clickConfirm(): void { + document.querySelector<HTMLButtonElement>("[data-testid='prompt-confirm']")?.click(); + } + + function clickCancel(): void { + document.querySelector<HTMLButtonElement>("[data-testid='prompt-cancel']")?.click(); + } + + it("renders title, optional label, and defaults", () => { + createPromptModal({ title: "Rename group", label: "Group name", onSubmit: vi.fn() }, container); + + expect(document.querySelector("h3")?.textContent).toBe("Rename group"); + expect(document.body.textContent).toContain("Group name"); + const input = getInput(); + expect(input.placeholder).toBe(""); + expect(input.getAttribute("maxlength")).toBe("100"); + expect( + document.querySelector<HTMLButtonElement>("[data-testid='prompt-confirm']")?.textContent, + ).toBe("Save"); + }); + + it("honors placeholder, maxLength, confirmLabel, testId, and initialValue", () => { + createPromptModal( + { + title: "t", + placeholder: "Type here", + maxLength: 32, + confirmLabel: "Rename", + testId: "rename-input", + initialValue: "old name", + onSubmit: vi.fn(), + }, + container, + ); + + const input = document.querySelector<HTMLInputElement>("[data-testid='rename-input']"); + expect(input).not.toBeNull(); + expect(input?.placeholder).toBe("Type here"); + expect(input?.getAttribute("maxlength")).toBe("32"); + expect(input?.value).toBe("old name"); + expect( + document.querySelector<HTMLButtonElement>("[data-testid='prompt-confirm']")?.textContent, + ).toBe("Rename"); + }); + + it("confirm submits the trimmed value and closes", () => { + const onSubmit = vi.fn(); + const inst = createPromptModal({ title: "t", onSubmit }, container); + + getInput().value = " spaced out "; + clickConfirm(); + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith("spaced out"); + expect(container.contains(inst.overlay)).toBe(false); + }); + + it("submits an empty value — clearing a name is a legitimate submission", () => { + const onSubmit = vi.fn(); + createPromptModal({ title: "t", initialValue: "old", onSubmit }, container); + + getInput().value = " "; + clickConfirm(); + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith(""); + }); + + it("Enter submits and prevents the default", () => { + const onSubmit = vi.fn(); + createPromptModal({ title: "t", onSubmit }, container); + + const input = getInput(); + input.value = "via enter"; + const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true }); + input.dispatchEvent(ev); + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith("via enter"); + expect(ev.defaultPrevented).toBe(true); + }); + + it("cancel closes without submitting and fires onClose", () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + const inst = createPromptModal({ title: "t", onSubmit, onClose }, container); + + getInput().value = "discarded"; + clickCancel(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(container.contains(inst.overlay)).toBe(false); + }); + + it("onClose fires on submit too — close and submit are one gesture", () => { + const onClose = vi.fn(); + createPromptModal({ title: "t", onSubmit: vi.fn(), onClose }, container); + + clickConfirm(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/Client/tauri-client/tests/unit/nsfw-gate.test.ts b/Client/tauri-client/tests/unit/nsfw-gate.test.ts index f5366682..9b088f88 100644 --- a/Client/tauri-client/tests/unit/nsfw-gate.test.ts +++ b/Client/tauri-client/tests/unit/nsfw-gate.test.ts @@ -4,6 +4,7 @@ import { acknowledgeNsfw, clearNsfwAcknowledgements, nsfwGateRequired, + setNsfwGateHost, } from "@lib/nsfw-gate"; import { createNsfwGate } from "@components/NsfwGate"; @@ -68,6 +69,34 @@ describe("nsfw-gate acknowledgements", () => { spy.mockRestore(); }); + describe("host scoping", () => { + afterEach(() => { + // currentHost is module-level state that outlives a single test. + setNsfwGateHost(null); + }); + + // Regression for v076: an in-app server switch is SPA navigation, not a + // reload, so sessionStorage survives it. An unscoped key meant an ack for + // channel N on server A silently suppressed the gate for the unrelated + // channel N on server B. + it("does not leak an acknowledgement across two server hosts", () => { + setNsfwGateHost("a.example.com"); + acknowledgeNsfw(12); + expect(isNsfwAcknowledged(12)).toBe(true); + + setNsfwGateHost("b.example.com"); + expect(isNsfwAcknowledged(12)).toBe(false); + + setNsfwGateHost("a.example.com"); + expect(isNsfwAcknowledged(12)).toBe(true); + }); + + it("falls back to the legacy unscoped key when no host has been set", () => { + acknowledgeNsfw(5); + expect(sessionStorage.getItem("owncord:nsfw-ack:5")).toBe("1"); + }); + }); + describe("nsfwGateRequired", () => { it("is false for a channel that is not flagged", () => { expect(nsfwGateRequired({ id: 1, nsfw: false })).toBe(false); diff --git a/Client/tauri-client/tests/unit/overlay-managers.test.ts b/Client/tauri-client/tests/unit/overlay-managers.test.ts index b66b4b12..fd00f044 100644 --- a/Client/tauri-client/tests/unit/overlay-managers.test.ts +++ b/Client/tauri-client/tests/unit/overlay-managers.test.ts @@ -165,6 +165,33 @@ describe("createInviteManagerController", () => { expect(mockInviteManagerMount).toHaveBeenCalledWith(root); }); + it("filters out revoked invites before handing the list to InviteManager", async () => { + // Redemption enforces `revoked = 0` server-side (UseInviteAtomic), so a + // revoked invite rendered like a live one hands out a code that always + // fails. The list endpoint deliberately still includes revoked invites + // (for admin visibility elsewhere), so the client must filter here. + const api = makeMockApi({ + getInvites: vi + .fn() + .mockResolvedValue([ + makeInviteResponse({ code: "live1" }), + makeInviteResponse({ code: "dead1", revoked: true }), + ]), + }); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + }); + + await controller.open(); + + const opts = (createInviteManager as Mock).mock.calls[0]![0] as { + invites: Array<{ code: string }>; + }; + expect(opts.invites.map((i) => i.code)).toEqual(["live1"]); + }); + it("onRevokeInvite catches API error and re-throws for component handling", async () => { const api = makeMockApi({ revokeInvite: vi.fn().mockRejectedValue(new Error("network error")), @@ -448,6 +475,29 @@ describe("createPinnedPanelController", () => { expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); }); + it("does not mount a second panel from a double-click while getPins is in flight", async () => { + let resolvePins: (value: { messages: unknown[] }) => void; + const pending = new Promise<{ messages: unknown[] }>((resolve) => { + resolvePins = resolve; + }); + const api = makeMockApi({ getPins: vi.fn().mockReturnValue(pending) }); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getCurrentChannelId: () => 42, + }); + + const first = controller.toggle(); + const second = controller.toggle(); // fires while getPins is still pending + + resolvePins!({ messages: [] }); + await Promise.all([first, second]); + + expect(mockPinnedMessagesMount).toHaveBeenCalledOnce(); + expect(api.getPins).toHaveBeenCalledOnce(); + }); + it("cleanup is safe when no panel is open", () => { const api = makeMockApi(); @@ -848,6 +898,32 @@ describe("createInviteManagerController (additional)", () => { expect(createInviteManager).toHaveBeenCalledOnce(); }); + it("does not mount a second overlay from a double-click while getInvites is in flight", async () => { + // `instance` is only assigned after the await, so a synchronous + // `instance !== null` guard alone lets a second call in while the first + // is still fetching — this is the concurrent case the sequential test + // above does not exercise. + let resolveInvites: (value: unknown[]) => void; + const pending = new Promise<unknown[]>((resolve) => { + resolveInvites = resolve; + }); + const api = makeMockApi({ getInvites: vi.fn().mockReturnValue(pending) }); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + }); + + const first = controller.open(); + const second = controller.open(); // fires while getInvites is still pending + + resolveInvites!([makeInviteResponse()]); + await Promise.all([first, second]); + + expect(createInviteManager).toHaveBeenCalledOnce(); + expect(api.getInvites).toHaveBeenCalledOnce(); + }); + it("cleanup destroys instance when open", async () => { const api = makeMockApi(); diff --git a/Client/tauri-client/tests/unit/pinned-messages.test.ts b/Client/tauri-client/tests/unit/pinned-messages.test.ts index 8f628a01..3f1c8cea 100644 --- a/Client/tauri-client/tests/unit/pinned-messages.test.ts +++ b/Client/tauri-client/tests/unit/pinned-messages.test.ts @@ -151,6 +151,31 @@ describe("PinnedMessages", () => { panel.destroy?.(); }); + // ── accessibility (DC-13): landmark panel, not a modal ───────────────────── + + it("exposes the panel as a labeled complementary landmark", () => { + const { panel } = makePanel(); + const root = container.querySelector(".pinned-panel") as HTMLElement; + expect(root.getAttribute("role")).toBe("complementary"); + expect(root.getAttribute("aria-label")).toBe("Pinned messages"); + // A side panel is not modal — it must never claim aria-modal. + expect(root.getAttribute("aria-modal")).toBeNull(); + panel.destroy?.(); + }); + + it("labels the icon-only buttons for screen readers", () => { + const { panel } = makePanel(); + + const closeBtn = container.querySelector(".pinned-panel__close") as HTMLButtonElement; + expect(closeBtn.getAttribute("aria-label")).toBe("Close pinned messages"); + + const actionBtns = container.querySelectorAll(".pinned-msg__actions button"); + // Jump is the first button in each action group, unpin the second + expect(actionBtns[0]!.getAttribute("aria-label")).toBe("Jump to message"); + expect(actionBtns[1]!.getAttribute("aria-label")).toBe("Unpin message"); + panel.destroy?.(); + }); + it("destroy removes DOM", () => { const { panel } = makePanel(); expect(container.querySelector(".pinned-panel")).not.toBeNull(); diff --git a/Client/tauri-client/tests/unit/ptt.test.ts b/Client/tauri-client/tests/unit/ptt.test.ts index d1df5353..f45a97af 100644 --- a/Client/tauri-client/tests/unit/ptt.test.ts +++ b/Client/tauri-client/tests/unit/ptt.test.ts @@ -22,6 +22,10 @@ const testPrefs = new Map<string, unknown>(); // voiceStore state let mockCurrentChannelId: number | null = null; +let mockLocalMuted = false; +let mockLocalDeafened = false; +const mockSetPttGated = vi.fn(); +const mockSetPttPollingLive = vi.fn(); // --------------------------------------------------------------------------- // Module mocks (must be declared before importing the module under test) @@ -44,8 +48,14 @@ vi.mock("@components/settings/helpers", () => ({ vi.mock("@stores/voice.store", () => ({ voiceStore: { - getState: () => ({ currentChannelId: mockCurrentChannelId }), + getState: () => ({ + currentChannelId: mockCurrentChannelId, + localMuted: mockLocalMuted, + localDeafened: mockLocalDeafened, + }), }, + setPttGated: (...args: unknown[]) => mockSetPttGated(...args), + setPttPollingLive: (...args: unknown[]) => mockSetPttPollingLive(...args), })); vi.mock("@lib/logger", () => ({ @@ -76,6 +86,10 @@ import { vkName, initPtt, stopPtt, updatePttKey, captureKeyPress } from "../../s function resetAll(): void { testPrefs.clear(); mockCurrentChannelId = null; + mockLocalMuted = false; + mockLocalDeafened = false; + mockSetPttGated.mockReset(); + mockSetPttPollingLive.mockReset(); mockInvoke.mockReset(); mockListen.mockReset(); // Default: invoke resolves with undefined; listen resolves with a no-op unlistener @@ -290,6 +304,45 @@ describe("initPtt", () => { expect(setKeyIdx).toBeLessThan(startIdx); }); + // v007: livekitSession mutes the mic at join when PTT is armed, and only a + // real ptt-state event can lift that mute. ptt_start spawns its thread on + // every platform, so the frontend must gate on the backend's capability + // answer instead — otherwise macOS (is_key_down stub) and pure-Wayland Linux + // (no reachable display) join muted with no way to ever unmute. + it("reports the backend's polling capability so join-time muting is safe", async () => { + testPrefs.set("pttVk", 0x20); + mockInvoke.mockImplementation((cmd: string) => + Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined), + ); + + await initPtt(); + + expect(mockInvoke).toHaveBeenCalledWith("ptt_polling_supported"); + expect(mockSetPttPollingLive).toHaveBeenCalledWith(true); + }); + + it("reports polling as NOT live when the backend cannot observe key state", async () => { + testPrefs.set("pttVk", 0x20); + mockInvoke.mockImplementation((cmd: string) => + Promise.resolve(cmd === "ptt_polling_supported" ? false : undefined), + ); + + await initPtt(); + + expect(mockSetPttPollingLive).toHaveBeenCalledWith(false); + expect(mockSetPttPollingLive).not.toHaveBeenCalledWith(true); + }); + + it("reports polling as NOT live when a backend command rejects", async () => { + testPrefs.set("pttVk", 0x20); + mockInvoke.mockRejectedValue(new Error("not in Tauri")); + + await initPtt(); + + expect(mockSetPttPollingLive).toHaveBeenCalledWith(false); + expect(mockSetPttPollingLive).not.toHaveBeenCalledWith(true); + }); + it("calls listen for 'ptt-state' events when key is non-zero", async () => { testPrefs.set("pttVk", 0x70); // F1 @@ -538,4 +591,162 @@ describe("ptt-state event listener", () => { await new Promise((r) => setTimeout(r, 0)); expect(mockSetMuted).not.toHaveBeenCalled(); }); + + it("does not call setMuted(false) when PTT is pressed while the user is self-muted (v006)", async () => { + const { setMuted } = await import("../../src/lib/livekitSession"); + const mockSetMuted = vi.mocked(setMuted); + mockSetMuted.mockClear(); + + mockCurrentChannelId = 7; + mockLocalMuted = true; // user explicitly muted themselves via the widget + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + + capturedCallback!({ payload: true }); // key pressed + + // Give the dynamic import a chance to resolve and (wrongly) call setMuted. + await new Promise((r) => setTimeout(r, 0)); + expect(mockSetMuted).not.toHaveBeenCalled(); + }); + + it("does not call setMuted(false) when PTT is pressed while the user is deafened (v006)", async () => { + const { setMuted } = await import("../../src/lib/livekitSession"); + const mockSetMuted = vi.mocked(setMuted); + mockSetMuted.mockClear(); + + mockCurrentChannelId = 7; + mockLocalDeafened = true; + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + + capturedCallback!({ payload: true }); + + await new Promise((r) => setTimeout(r, 0)); + expect(mockSetMuted).not.toHaveBeenCalled(); + }); + + it("still unmutes on press when the user is not self-muted or deafened", async () => { + const { setMuted } = await import("../../src/lib/livekitSession"); + const mockSetMuted = vi.mocked(setMuted); + mockSetMuted.mockClear(); + + mockCurrentChannelId = 7; + mockLocalMuted = false; + mockLocalDeafened = false; + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + + capturedCallback!({ payload: true }); + + await vi.waitFor(() => { + expect(mockSetMuted).toHaveBeenCalledWith(false); + }); + }); + + it("updates pttGated in the store on press and release", async () => { + mockCurrentChannelId = 7; + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + mockSetPttGated.mockClear(); + + capturedCallback!({ payload: true }); // pressed — gate open + expect(mockSetPttGated).toHaveBeenCalledWith(false); + + capturedCallback!({ payload: false }); // released — gate closed + expect(mockSetPttGated).toHaveBeenCalledWith(true); + }); + + it("keeps unmuting on every press even though a release writes localMuted (v006)", async () => { + const { setMuted } = await import("../../src/lib/livekitSession"); + const mockSetMuted = vi.mocked(setMuted); + mockSetMuted.mockReset(); + // livekitSession.setMuted() writes localMuted for every caller, PTT + // included — the self-mute guard must not read that write back as an + // explicit self-mute, or PTT unmutes exactly once and is dead after. + mockSetMuted.mockImplementation((muted: boolean) => { + mockLocalMuted = muted; + }); + + mockCurrentChannelId = 7; + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + + capturedCallback!({ payload: true }); + await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false)); + capturedCallback!({ payload: false }); + await vi.waitFor(() => expect(mockLocalMuted).toBe(true)); + + mockSetMuted.mockClear(); + capturedCallback!({ payload: true }); // second press + await vi.waitFor(() => { + expect(mockSetMuted).toHaveBeenCalledWith(false); + }); + }); + + it("stays muted on the press after the user self-mutes mid-hold (v006)", async () => { + const { setMuted } = await import("../../src/lib/livekitSession"); + const mockSetMuted = vi.mocked(setMuted); + mockSetMuted.mockReset(); + mockSetMuted.mockImplementation((muted: boolean) => { + mockLocalMuted = muted; + }); + + mockCurrentChannelId = 7; + testPrefs.set("pttVk", 0x20); + + let capturedCallback: ((event: { payload: boolean }) => void) | null = null; + mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => { + capturedCallback = cb; + return Promise.resolve(() => {}); + }); + + await initPtt(); + + capturedCallback!({ payload: true }); + await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false)); + // The user hits the widget mute button while still holding the key. + mockLocalMuted = true; + capturedCallback!({ payload: false }); // release — mic was already muted + await new Promise((r) => setTimeout(r, 0)); + + mockSetMuted.mockClear(); + capturedCallback!({ payload: true }); // next press must not republish + await new Promise((r) => setTimeout(r, 0)); + expect(mockSetMuted).not.toHaveBeenCalled(); + }); }); diff --git a/Client/tauri-client/tests/unit/quick-switcher.test.ts b/Client/tauri-client/tests/unit/quick-switcher.test.ts index 51d19bab..20c441dd 100644 --- a/Client/tauri-client/tests/unit/quick-switcher.test.ts +++ b/Client/tauri-client/tests/unit/quick-switcher.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createQuickSwitcher } from "@components/QuickSwitcher"; import type { QuickSwitcherOptions } from "@components/QuickSwitcher"; import { channelsStore, setChannels } from "@stores/channels.store"; +import { addDmToChannelsStore } from "../../src/pages/main-page/SidebarDmHelpers"; +import type { DmChannel } from "@stores/dm.store"; import type { ReadyChannel } from "../../src/lib/types"; function resetStore(): void { @@ -250,6 +252,33 @@ describe("QuickSwitcher", () => { expect(container.querySelectorAll(".quick-switcher__item").length).toBe(0); }); + it("does not list DM rows synthesized into channelsStore (they belong to the DM sidebar section)", () => { + const dm: DmChannel = { + channelId: 99, + recipient: { id: 1, username: "bob", avatar: "", status: "online" }, + participants: [{ id: 1, username: "bob", avatar: "", status: "online" }], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + }; + addDmToChannelsStore(dm); + + switcher.mount(container); + + // Still just the 4 text/voice channels — the synthesized DM row is not + // duplicated here, and selecting it here would bypass clearDmUnread. + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(4); + const names = Array.from(items).map( + (el) => el.querySelector(".quick-switcher__name")?.textContent, + ); + expect(names).not.toContain("bob"); + }); + it("does not show category for channels without one", () => { switcher.mount(container); @@ -259,4 +288,73 @@ describe("QuickSwitcher", () => { expect(lastItem.querySelector(".quick-switcher__name")?.textContent).toBe("announcements"); expect(lastItem.querySelector(".quick-switcher__category")).toBeNull(); }); + + it("applies dialog semantics to the quick switcher modal", () => { + switcher.mount(container); + const modal = container.querySelector(".quick-switcher") as HTMLDivElement; + expect(modal.getAttribute("role")).toBe("dialog"); + expect(modal.getAttribute("aria-modal")).toBe("true"); + expect(modal.getAttribute("aria-label")).toBe("Quick switcher"); + }); + + it("wires the input as a combobox controlling the results listbox", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + expect(input.getAttribute("role")).toBe("combobox"); + expect(input.getAttribute("aria-expanded")).toBe("true"); + expect(input.getAttribute("aria-autocomplete")).toBe("list"); + expect(input.getAttribute("aria-controls")).toBe("quick-switcher-results"); + + const results = container.querySelector("#quick-switcher-results"); + expect(results).not.toBeNull(); + expect(results!.getAttribute("role")).toBe("listbox"); + expect(results!.classList.contains("quick-switcher__results")).toBe(true); + }); + + it("marks result rows as options with aria-selected on the active row", () => { + switcher.mount(container); + const items = container.querySelectorAll(".quick-switcher__item"); + items.forEach((item, i) => { + expect(item.getAttribute("role")).toBe("option"); + expect(item.id).toBe(`qs-option-${i}`); + expect(item.getAttribute("aria-selected")).toBe(i === 0 ? "true" : "false"); + }); + }); + + it("aria-activedescendant follows arrow-key navigation", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-0"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-1"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-0"); + }); + + it("clears aria-activedescendant when no results match", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.value = "zzzznotachannel"; + input.dispatchEvent(new Event("input")); + + expect(input.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("moves focus into the dialog on mount and restores it on destroy", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + expect(document.activeElement).toBe(input); + + switcher.destroy?.(); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); }); diff --git a/Client/tauri-client/tests/unit/reaction-controller.test.ts b/Client/tauri-client/tests/unit/reaction-controller.test.ts index 46995bfd..b041ce3a 100644 --- a/Client/tauri-client/tests/unit/reaction-controller.test.ts +++ b/Client/tauri-client/tests/unit/reaction-controller.test.ts @@ -4,15 +4,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mocks // --------------------------------------------------------------------------- -const { mockGetChannelMessages, createMockEmojiPickerElement, mockEmojiPickerDestroy } = vi.hoisted( - () => ({ - mockGetChannelMessages: vi.fn( - (): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => [], - ), - createMockEmojiPickerElement: () => document.createElement("div"), - mockEmojiPickerDestroy: vi.fn(), - }), -); +const { + mockGetChannelMessages, + mockAddOptimisticReaction, + createMockEmojiPickerElement, + mockEmojiPickerDestroy, +} = vi.hoisted(() => ({ + mockGetChannelMessages: vi.fn( + (): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => [], + ), + mockAddOptimisticReaction: vi.fn(), + createMockEmojiPickerElement: () => document.createElement("div"), + mockEmojiPickerDestroy: vi.fn(), +})); vi.mock("@lib/dom", () => ({ createElement: vi.fn((tag: string, attrs?: Record<string, string>) => { @@ -44,6 +48,7 @@ vi.mock("@components/EmojiPicker", () => ({ vi.mock("@stores/messages.store", () => ({ getChannelMessages: mockGetChannelMessages, + addOptimisticReaction: mockAddOptimisticReaction, })); // --------------------------------------------------------------------------- @@ -58,7 +63,7 @@ import type { ReactionControllerOptions } from "../../src/pages/main-page/Reacti // --------------------------------------------------------------------------- function makeWs(): ReactionControllerOptions["ws"] { - return { send: vi.fn() } as unknown as ReactionControllerOptions["ws"]; + return { send: vi.fn(() => "corr-1") } as unknown as ReactionControllerOptions["ws"]; } function makeLimiter(allowed = true): ReactionControllerOptions["reactionsLimiter"] { @@ -129,6 +134,35 @@ describe("createReactionController", () => { expect(opts.ws.send).not.toHaveBeenCalled(); expect(opts.showError).toHaveBeenCalledWith("Slow down! Please wait before reacting again."); + expect(mockAddOptimisticReaction).not.toHaveBeenCalled(); + }); + + it("registers the optimistic toggle under the send's correlation id", () => { + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [] }]); + const ctrl = createReactionController(makeOpts()); + + ctrl.handleReaction(1, "👍"); + + expect(mockAddOptimisticReaction).toHaveBeenCalledWith("corr-1", { + channelId: 42, + messageId: 1, + emoji: "👍", + action: "add", + }); + }); + + it("registers an optimistic remove when toggling off", () => { + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [{ emoji: "👍", me: true }] }]); + const ctrl = createReactionController(makeOpts()); + + ctrl.handleReaction(1, "👍"); + + expect(mockAddOptimisticReaction).toHaveBeenCalledWith("corr-1", { + channelId: 42, + messageId: 1, + emoji: "👍", + action: "remove", + }); }); }); diff --git a/Client/tauri-client/tests/unit/reaction-tooltip.test.ts b/Client/tauri-client/tests/unit/reaction-tooltip.test.ts index 8634cef3..85d28025 100644 --- a/Client/tauri-client/tests/unit/reaction-tooltip.test.ts +++ b/Client/tauri-client/tests/unit/reaction-tooltip.test.ts @@ -313,4 +313,45 @@ describe("attachReactionTooltip", () => { expect(fetcher).not.toHaveBeenCalled(); }); + + // Regression: a full MessageList rebuild re-attaches every visible chip + // against the same visit-long signal. A bare per-chip `abort` listener + // never got removed, so every past rebuild's chips (and their detached + // rows) stayed pinned in memory for the rest of the channel visit. + it("registers only one abort listener per signal no matter how many chips attach", () => { + const ac = new AbortController(); + const addEventListenerSpy = vi.spyOn(ac.signal, "addEventListener"); + + // Simulate many rebuilds, each re-creating a fresh chip element and + // re-attaching tooltip behaviour against the same long-lived signal. + for (let i = 0; i < 50; i++) { + const chip = document.createElement("span"); + document.body.appendChild(chip); + attachReactionTooltip( + chip, + { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, + ac.signal, + ); + } + + const abortRegistrations = addEventListenerSpy.mock.calls.filter(([type]) => type === "abort"); + expect(abortRegistrations).toHaveLength(1); + }); + + it("hides every currently-hovering chip on a signal when it aborts, not just the first attached", () => { + vi.useFakeTimers(); + const ac = new AbortController(); + const chipA = document.createElement("span"); + const chipB = document.createElement("span"); + document.body.append(chipA, chipB); + attachReactionTooltip(chipA, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + attachReactionTooltip(chipB, { channelId: 5, messageId: 43, emoji: "🎉", count: 1 }, ac.signal); + + chipA.dispatchEvent(new Event("mouseenter")); + chipB.dispatchEvent(new Event("mouseenter")); + ac.abort(); + vi.advanceTimersByTime(REACTION_TOOLTIP_DEBOUNCE_MS * 2); + + expect(fetcher).not.toHaveBeenCalled(); + }); }); diff --git a/Client/tauri-client/tests/unit/read-state.test.ts b/Client/tauri-client/tests/unit/read-state.test.ts index 503d8071..1ca0424f 100644 --- a/Client/tauri-client/tests/unit/read-state.test.ts +++ b/Client/tauri-client/tests/unit/read-state.test.ts @@ -147,6 +147,84 @@ describe("unreadChannelIds / markAllRead", () => { expect(markAllRead()).toBe(0); expect(sent).toEqual([]); }); + + // Regression for v056: the server's mark_read handler shares a 5/s budget + // with channel_focus and silently drops frames over it. Marking more + // channels than the budget synchronously used to clear every local badge + // up front while the server dropped the excess — the dropped channels' + // badges then resurrected on the next `ready`. Bursts must stay paced. + it("paces a burst larger than the server's per-second mark_read budget", () => { + vi.useFakeTimers(); + try { + setChannels([ + channel(1, 1), + channel(2, 1), + channel(3, 1), + channel(4, 1), + channel(5, 1), + channel(6, 1), + ]); + + expect(markAllRead()).toBe(6); + + // Only the first burst goes out synchronously. + expect(sent.length).toBeLessThanOrEqual(4); + const firstBurst = new Set(sent); + const deferredId = [1, 2, 3, 4, 5, 6].find((id) => !firstBurst.has(id))!; + + // A channel whose send hasn't fired yet must not have had its local + // badge cleared early — clearing must track the send, not precede it. + expect(hasUnread(deferredId)).toBe(true); + + // The rest lands only after the pacing interval, one budget-window later. + vi.advanceTimersByTime(2000); + expect(sent.length).toBe(6); + expect(new Set(sent)).toEqual(new Set([1, 2, 3, 4, 5, 6])); + expect(hasUnread(deferredId)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + // The pacing above defers sends past the point where the connection can be + // replaced. Channel ids are only unique per server, so a queued send that + // survives a server switch would mark the NEW server's same-numbered channel + // read. Registering a sender is the one signal read-state gets that the + // connection changed (MainPage does it once per session). + it("drops queued sends when a new connection registers its sender", () => { + vi.useFakeTimers(); + try { + setChannels([channel(1, 1), channel(2, 1), channel(3, 1), channel(4, 1), channel(5, 1)]); + markAllRead(); + expect(sent.length).toBeLessThanOrEqual(4); + + // New session: new sender, new (unrelated) channel list reusing the ids. + const next: number[] = []; + setMarkReadSender((id) => next.push(id)); + setChannels([channel(1, 1), channel(2, 1), channel(3, 1), channel(4, 1), channel(5, 1)]); + + vi.advanceTimersByTime(5000); + expect(next).toEqual([]); + expect(unreadChannelIds()).toHaveLength(5); + } finally { + vi.useRealTimers(); + } + }); + + it("supersedes an in-flight burst rather than double-sending it", () => { + vi.useFakeTimers(); + try { + setChannels([channel(1, 1), channel(2, 1), channel(3, 1), channel(4, 1), channel(5, 1)]); + markAllRead(); + markAllRead(); + + vi.advanceTimersByTime(5000); + expect(sent.length).toBe(5); + expect(new Set(sent)).toEqual(new Set([1, 2, 3, 4, 5])); + } finally { + vi.useRealTimers(); + } + }); }); describe("mark_read wiring", () => { diff --git a/Client/tauri-client/tests/unit/reconcile.test.ts b/Client/tauri-client/tests/unit/reconcile.test.ts deleted file mode 100644 index 2075b9ec..00000000 --- a/Client/tauri-client/tests/unit/reconcile.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { reconcileList } from "../../src/lib/reconcile"; - -interface Item { - id: string; - label: string; -} - -function makeContainer(): HTMLDivElement { - return document.createElement("div"); -} - -function makeItem(id: string, label: string): Item { - return { id, label }; -} - -function createEl(item: Item): HTMLDivElement { - const el = document.createElement("div"); - el.textContent = item.label; - el.setAttribute("data-reconcile-key", item.id); - return el; -} - -function updateEl(el: Element, item: Item): void { - el.textContent = item.label; -} - -function getKeys(container: Element): string[] { - return Array.from(container.children).map((c) => c.getAttribute("data-reconcile-key") ?? ""); -} - -describe("reconcileList", () => { - it("inserts new items into empty container", () => { - const container = makeContainer(); - const items = [makeItem("a", "A"), makeItem("b", "B")]; - - reconcileList({ - container, - items, - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - expect(container.children.length).toBe(2); - expect(getKeys(container)).toEqual(["a", "b"]); - expect(container.children[0]!.textContent).toBe("A"); - expect(container.children[1]!.textContent).toBe("B"); - }); - - it("removes deleted items", () => { - const container = makeContainer(); - const items = [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")]; - - reconcileList({ - container, - items, - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - expect(container.children.length).toBe(3); - - // Remove 'b' - reconcileList({ - container, - items: [makeItem("a", "A"), makeItem("c", "C")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - expect(container.children.length).toBe(2); - expect(getKeys(container)).toEqual(["a", "c"]); - }); - - it("reorders moved items", () => { - const container = makeContainer(); - const items = [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")]; - - reconcileList({ - container, - items, - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - // Reverse order - reconcileList({ - container, - items: [makeItem("c", "C"), makeItem("b", "B"), makeItem("a", "A")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - expect(getKeys(container)).toEqual(["c", "b", "a"]); - }); - - it("updates changed items in-place (preserves DOM reference)", () => { - const container = makeContainer(); - const items = [makeItem("a", "A"), makeItem("b", "B")]; - - reconcileList({ - container, - items, - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - const origA = container.children[0]!; - const origB = container.children[1]!; - - // Update label for 'a' - reconcileList({ - container, - items: [makeItem("a", "A-updated"), makeItem("b", "B")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - // SAME DOM elements — not rebuilt - expect(container.children[0]).toBe(origA); - expect(container.children[1]).toBe(origB); - expect(origA.textContent).toBe("A-updated"); - }); - - it("handles empty → items", () => { - const container = makeContainer(); - - reconcileList({ - container, - items: [], - key: (i: Item) => i.id, - create: createEl, - update: updateEl, - }); - expect(container.children.length).toBe(0); - - reconcileList({ - container, - items: [makeItem("x", "X")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - expect(container.children.length).toBe(1); - expect(getKeys(container)).toEqual(["x"]); - }); - - it("handles items → empty", () => { - const container = makeContainer(); - - reconcileList({ - container, - items: [makeItem("a", "A"), makeItem("b", "B")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - expect(container.children.length).toBe(2); - - reconcileList({ - container, - items: [], - key: (i: Item) => i.id, - create: createEl, - update: updateEl, - }); - expect(container.children.length).toBe(0); - }); - - it("no-op when identical items", () => { - const container = makeContainer(); - const items = [makeItem("a", "A"), makeItem("b", "B")]; - const createSpy = vi.fn(createEl); - - reconcileList({ - container, - items, - key: (i) => i.id, - create: createSpy, - update: updateEl, - }); - - const origA = container.children[0]!; - const origB = container.children[1]!; - createSpy.mockClear(); - - // Same items again - reconcileList({ - container, - items: [makeItem("a", "A"), makeItem("b", "B")], - key: (i) => i.id, - create: createSpy, - update: updateEl, - }); - - // No new elements created - expect(createSpy).not.toHaveBeenCalled(); - // Same DOM references - expect(container.children[0]).toBe(origA); - expect(container.children[1]).toBe(origB); - }); - - it("handles simultaneous add, remove, and reorder", () => { - const container = makeContainer(); - - reconcileList({ - container, - items: [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - const origC = container.children[2]!; - - // Remove 'a', add 'd', reorder: c, d, b - reconcileList({ - container, - items: [makeItem("c", "C"), makeItem("d", "D"), makeItem("b", "B")], - key: (i) => i.id, - create: createEl, - update: updateEl, - }); - - expect(getKeys(container)).toEqual(["c", "d", "b"]); - expect(container.children.length).toBe(3); - // 'c' element preserved - expect(container.children[0]).toBe(origC); - }); -}); diff --git a/Client/tauri-client/tests/unit/room-event-handlers.test.ts b/Client/tauri-client/tests/unit/room-event-handlers.test.ts index 477a57d8..a86c3259 100644 --- a/Client/tauri-client/tests/unit/room-event-handlers.test.ts +++ b/Client/tauri-client/tests/unit/room-event-handlers.test.ts @@ -99,6 +99,7 @@ function build(over: Partial<RoomEventDeps> = {}): Harness { getOnRemoteVideoRemovedCallback: () => spies.onRemoteVideoRemoved, getOnErrorCallback: () => spies.onError, isConnecting: () => false, + isReconnecting: () => false, getLatestToken: () => "tok", getLastUrl: () => "wss://lk.example", getLastDirectUrl: () => undefined, @@ -521,6 +522,22 @@ describe("handleDisconnected", () => { expect(h.spies.leaveVoice).not.toHaveBeenCalled(); }); + // The bundled livekit-client emits RoomEvent.Disconnected (synchronously, + // before rejecting) on EVERY failed reconnect attempt inside the retry + // loop's own room.connect() call — including while the active reconnect + // loop is still running with the attempt room's listeners attached. Without + // this guard that re-entrant Disconnected starts a SECOND, uncancellable + // attemptAutoReconnect loop whose AbortController is stored nowhere. + it("defers to the active reconnect loop while already reconnecting", () => { + const h = build({ isConnecting: () => false, isReconnecting: () => true }); + + h.handlers.handleDisconnected(DisconnectReason.SERVER_SHUTDOWN); + + expect(h.spies.attemptAutoReconnect).not.toHaveBeenCalled(); + expect(h.spies.leaveVoice).not.toHaveBeenCalled(); + expect(h.spies.teardownForReconnect).not.toHaveBeenCalled(); + }); + it("auto-reconnects on an unexpected disconnect", () => { const h = build(); diff --git a/Client/tauri-client/tests/unit/server-strip.test.ts b/Client/tauri-client/tests/unit/server-strip.test.ts deleted file mode 100644 index ea08f14f..00000000 --- a/Client/tauri-client/tests/unit/server-strip.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createServerStrip } from "@components/ServerStrip"; - -describe("ServerStrip", () => { - let container: HTMLDivElement; - let comp: ReturnType<typeof createServerStrip>; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - comp?.destroy?.(); - container.remove(); - }); - - it("mounts with server-strip class", () => { - comp = createServerStrip(); - comp.mount(container); - - expect(container.querySelector(".server-strip")).not.toBeNull(); - }); - - it('renders home icon with "O"', () => { - comp = createServerStrip(); - comp.mount(container); - - const icons = container.querySelectorAll(".server-icon"); - const homeIcon = icons[0]; - expect(homeIcon).not.toBeUndefined(); - expect(homeIcon?.textContent).toBe("O"); - }); - - it("renders separator", () => { - comp = createServerStrip(); - comp.mount(container); - - expect(container.querySelector(".server-separator")).not.toBeNull(); - }); - - it('renders add icon with "+"', () => { - comp = createServerStrip(); - comp.mount(container); - - const addIcon = container.querySelector(".server-icon.add"); - expect(addIcon).not.toBeNull(); - expect(addIcon?.textContent).toBe("+"); - }); - - it("home icon has active class", () => { - comp = createServerStrip(); - comp.mount(container); - - const icons = container.querySelectorAll(".server-icon"); - const homeIcon = icons[0]; - expect(homeIcon?.classList.contains("active")).toBe(true); - }); - - it("destroy removes DOM", () => { - comp = createServerStrip(); - comp.mount(container); - - expect(container.querySelector(".server-strip")).not.toBeNull(); - - comp.destroy?.(); - expect(container.querySelector(".server-strip")).toBeNull(); - }); -}); diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index ef27af9e..bacd938d 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -760,6 +760,101 @@ describe("SettingsOverlay", () => { overlay.destroy?.(); }); + // --- Dialog & tablist semantics (DC-13) --- + + it("stamps dialog semantics on the settings panel", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const panel = container.querySelector(".settings-panel") as HTMLElement; + expect(panel.getAttribute("role")).toBe("dialog"); + expect(panel.getAttribute("aria-modal")).toBe("true"); + expect(panel.getAttribute("aria-label")).toBe("Settings"); + + overlay.destroy?.(); + }); + + it("marks the sidebar as a vertical tablist", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const sidebar = container.querySelector(".settings-sidebar") as HTMLElement; + expect(sidebar.getAttribute("role")).toBe("tablist"); + expect(sidebar.getAttribute("aria-orientation")).toBe("vertical"); + expect(sidebar.getAttribute("aria-label")).toBe("Settings sections"); + + overlay.destroy?.(); + }); + + it("moves the roving tabindex to the tab activated by setActiveTab", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const accountTab = getTab(container, 0); + const appearanceTab = getTab(container, 1); + expect(accountTab.getAttribute("tabindex")).toBe("0"); + expect(appearanceTab.getAttribute("tabindex")).toBe("-1"); + + appearanceTab.click(); + + expect(accountTab.getAttribute("tabindex")).toBe("-1"); + expect(appearanceTab.getAttribute("tabindex")).toBe("0"); + + overlay.destroy?.(); + }); + + it("ArrowDown moves focus and activation to the next tab", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const accountTab = getTab(container, 0); + const appearanceTab = getTab(container, 1); + accountTab.focus(); + accountTab.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + + expect(document.activeElement).toBe(appearanceTab); + expect(appearanceTab.classList.contains("active")).toBe(true); + expect(appearanceTab.getAttribute("aria-selected")).toBe("true"); + expect(accountTab.classList.contains("active")).toBe(false); + + overlay.destroy?.(); + }); + + it("labels the content tabpanel with the active tab", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const content = container.querySelector(".settings-content") as HTMLElement; + expect(content.getAttribute("role")).toBe("tabpanel"); + expect(getTab(container, 0).id).toBe("settings-tab-account"); + expect(content.getAttribute("aria-labelledby")).toBe("settings-tab-account"); + + // "Text & Images" — the slugged id drops the ampersand + getTab(container, 3).click(); + expect(content.getAttribute("aria-labelledby")).toBe("settings-tab-text-images"); + + overlay.destroy?.(); + }); + + it("restores focus to the opener when the overlay closes", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + overlay.open(); + + const panel = container.querySelector(".settings-panel") as HTMLElement; + expect(panel.contains(document.activeElement)).toBe(true); + + overlay.close(); + expect(document.activeElement).toBe(opener); + + overlay.destroy?.(); + opener.remove(); + }); + // --- Username validation --- it("rejects single-character username (min 2)", () => { diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index 5c4e3300..e0d3986a 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -1657,6 +1657,56 @@ describe("SidebarArea", () => { cleanup(result); }); + + it("onReorderChannel surfaces a failed write as an error toast", async () => { + // Previously fired with bare `void` and no .catch: a rejected PATCH left + // the sidebar showing an order the server never accepted, with nothing + // telling the admin. + const opts = defaultOpts(); + const toast = { show: vi.fn() }; + (opts.getToast as MockedFn).mockReturnValue(toast); + (opts.api.adminUpdateChannel as MockedFn) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("forbidden")); + const result = createSidebarArea(opts); + container.appendChild(result.sidebarWrapper); + + const callArgs = (createChannelSidebar as MockedFn).mock.calls[0]![0]; + callArgs.onReorderChannel([ + { channelId: 1, newPosition: 0 }, + { channelId: 2, newPosition: 1 }, + ]); + + // Flush the Promise.allSettled(...).then(...) microtask chain. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(opts.api.adminUpdateChannel).toHaveBeenCalledWith(1, { position: 0 }); + expect(opts.api.adminUpdateChannel).toHaveBeenCalledWith(2, { position: 1 }); + expect(toast.show).toHaveBeenCalledWith("Failed to save channel order", "error"); + + cleanup(result); + }); + + it("onReorderChannel does not toast when every write succeeds", async () => { + const opts = defaultOpts(); + const toast = { show: vi.fn() }; + (opts.getToast as MockedFn).mockReturnValue(toast); + const result = createSidebarArea(opts); + container.appendChild(result.sidebarWrapper); + + const callArgs = (createChannelSidebar as MockedFn).mock.calls[0]![0]; + callArgs.onReorderChannel([{ channelId: 1, newPosition: 0 }]); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(toast.show).not.toHaveBeenCalled(); + + cleanup(result); + }); }); // ------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/stream-preview.test.ts b/Client/tauri-client/tests/unit/stream-preview.test.ts index a16d225f..efbd64ad 100644 --- a/Client/tauri-client/tests/unit/stream-preview.test.ts +++ b/Client/tauri-client/tests/unit/stream-preview.test.ts @@ -289,6 +289,128 @@ describe("streamPreview", () => { expect(getPreview(row)).toBeNull(); }); + // Abort-listener accumulation (leak fix) + it("registers only one abort listener per signal, not one per attach call", () => { + mockGetRemoteVideoStream.mockReturnValue(null); + const addSpy = vi.spyOn(ac.signal, "addEventListener"); + const row1 = createRow(1); + const row2 = createRow(2); + + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); + // A re-render re-attaches the same row to the same sidebar-lifetime signal. + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + + const abortCalls = addSpy.mock.calls.filter(([type]) => type === "abort"); + expect(abortCalls).toHaveLength(1); + }); + + // v091: a structural re-render (clearChildren + rebuild) detaches the old + // row from the DOM without running any preview cleanup on it. Without + // retiring it, that row (and any live track-event listeners it registered) + // is retained by the shared rowsBySignal map for the sidebar's entire + // lifetime instead of being cleaned up as soon as the next render proves + // it's dead. + it("cleans up a superseded row's track listeners as soon as the next attach call sees it (v091)", () => { + const stream = createMockMediaStream(); + const track = stream.getVideoTracks()[0]!; + const removeSpy = vi.spyOn(track, "removeEventListener"); + mockGetRemoteVideoStream.mockReturnValue(stream); + + const row1 = createRow(1); + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + row1.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(300); + expect(getPreview(row1)).not.toBeNull(); + + // Simulate renderChannels()'s clearChildren + rebuild: the old row (and + // its preview sibling) is removed from the DOM directly, without going + // through hidePreview/mouseleave. + row1.remove(); + expect(removeSpy).not.toHaveBeenCalled(); + + // The next render re-attaches a fresh row for (possibly) the same user + // to the same sidebar-lifetime signal. + const row2 = createRow(1); + attachStreamPreview(row2, 1, "Alice", false, true, ac.signal); + + // Retiring the dead row1 entry must have run its cleanup immediately — + // not deferred until the signal eventually aborts. + expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function)); + }); + + it("leaves another user's live row alone when a new row attaches", () => { + mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); + const row1 = createRow(1); + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + row1.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(300); + expect(getPreview(row1)).not.toBeNull(); + + // A second row attaches to the same signal while row1 is still live. + const row2 = createRow(2); + attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); + + // row1's preview must be untouched — only the row a re-render replaced + // for the *same* user is retired. + expect(getPreview(row1)).not.toBeNull(); + }); + + // ChannelSidebar builds a whole category subtree (rows included) and only + // appends it to the live channel list afterwards, so every row is still + // disconnected when attachStreamPreview runs. Deciding which tracked rows + // are dead by liveness at that moment therefore drops rows that are about + // to be inserted, losing their abort-time cleanup entirely (v091). + it("still tracks rows attached before their subtree is inserted (v091)", () => { + const stream = createMockMediaStream(); + const track = stream.getVideoTracks()[0]!; + const removeSpy = vi.spyOn(track, "removeEventListener"); + mockGetRemoteVideoStream.mockReturnValue(stream); + + const group = document.createElement("div"); + const row1 = document.createElement("div"); + row1.className = "voice-user-item"; + const row2 = document.createElement("div"); + row2.className = "voice-user-item"; + group.appendChild(row1); + group.appendChild(row2); + + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); + document.body.appendChild(group); + + row1.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(300); + expect(getPreview(row1)).not.toBeNull(); + + ac.abort(); + + expect(getPreview(row1)).toBeNull(); + expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function)); + }); + + it("still cleans up every row attached to a signal when it aborts", () => { + mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); + const row1 = createRow(1); + const row2 = createRow(2); + attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); + attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); + + row1.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(300); + row2.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(300); + expect(getPreview(row1)).not.toBeNull(); + expect(getPreview(row2)).not.toBeNull(); + + ac.abort(); + + expect(getPreview(row1)).toBeNull(); + expect(getPreview(row2)).toBeNull(); + }); + // Track mute event → placeholder it("swaps to placeholder on track mute event", () => { const stream = createMockMediaStream(); diff --git a/Client/tauri-client/tests/unit/toast.test.ts b/Client/tauri-client/tests/unit/toast.test.ts index b09a30b7..a9eb1124 100644 --- a/Client/tauri-client/tests/unit/toast.test.ts +++ b/Client/tauri-client/tests/unit/toast.test.ts @@ -94,6 +94,22 @@ describe("ToastContainer", () => { expect(container.querySelectorAll(".toast").length).toBe(0); }); + it("mounts the container as a polite live region", () => { + const region = container.querySelector(".toast-container"); + expect(region).not.toBeNull(); + expect(region!.getAttribute("role")).toBe("status"); + expect(region!.getAttribute("aria-live")).toBe("polite"); + expect(region!.getAttribute("aria-atomic")).toBe("false"); + }); + + it("announces toasts by appending them inside the live region", () => { + toast.show("Announced"); + + const region = container.querySelector(".toast-container"); + const toastEl = container.querySelector(".toast"); + expect(toastEl!.parentElement).toBe(region); + }); + it("destroy clears all toasts and removes root", () => { toast.show("Will be destroyed"); toast.destroy?.(); diff --git a/Client/tauri-client/tests/unit/typing-indicator.test.ts b/Client/tauri-client/tests/unit/typing-indicator.test.ts index a211d7a8..687da32a 100644 --- a/Client/tauri-client/tests/unit/typing-indicator.test.ts +++ b/Client/tauri-client/tests/unit/typing-indicator.test.ts @@ -51,6 +51,15 @@ describe("TypingIndicator", () => { expect(container.querySelector(".typing-bar")).not.toBeNull(); }); + it("mounts as a polite live region so typing changes are announced", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.getAttribute("role")).toBe("status"); + expect(bar.getAttribute("aria-live")).toBe("polite"); + }); + it("shows nothing when no one is typing", () => { comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); comp.mount(container); diff --git a/Client/tauri-client/tests/unit/video-mode-controller.test.ts b/Client/tauri-client/tests/unit/video-mode-controller.test.ts index 1b8ac7bf..342b1556 100644 --- a/Client/tauri-client/tests/unit/video-mode-controller.test.ts +++ b/Client/tauri-client/tests/unit/video-mode-controller.test.ts @@ -489,4 +489,139 @@ describe("createVideoModeController", () => { ctrl.showChat(); expect(ctrl.getFocusedTileId()).toBeNull(); }); + + describe("sticky video-grid dismissal (v048)", () => { + it("showChat while local video is on stays dismissed through a later checkVideoMode", () => { + const users = new Map([ + [1, { userId: 1, camera: false, screenshare: false, username: "me" }], + ]); + mockVoiceStoreGetState.mockReturnValue( + makeVoiceState({ + currentChannelId: 10, + localCamera: true, + voiceUsers: new Map([[10, users]]), + }), + ); + + const slots = makeSlots(); + const ctrl = createVideoModeController({ + slots, + videoGrid: makeVideoGrid(), + getCurrentUserId: () => 1, + }); + + // Auto-opens because local camera is on. + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(true); + + // User switches to a text channel — explicit dismissal. + ctrl.showChat(); + expect(ctrl.isVideoMode()).toBe(false); + + // A remote peer's camera toggling re-invokes checkVideoMode(); local + // video is still on, but the dismissal must stick. + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(false); + expect(slots.messagesSlot.style.display).toBe(""); + }); + + it("re-arms auto-open once local video turns off after a dismissal", () => { + const users = new Map([ + [1, { userId: 1, camera: false, screenshare: false, username: "me" }], + ]); + const state = makeVoiceState({ + currentChannelId: 10, + localCamera: true, + voiceUsers: new Map([[10, users]]), + }); + mockVoiceStoreGetState.mockReturnValue(state); + + const ctrl = createVideoModeController({ + slots: makeSlots(), + videoGrid: makeVideoGrid(), + getCurrentUserId: () => 1, + }); + + ctrl.checkVideoMode(); + ctrl.showChat(); + expect(ctrl.isVideoMode()).toBe(false); + + // Local camera turns off entirely — dismissal is cleared. + mockVoiceStoreGetState.mockReturnValue({ ...state, localCamera: false }); + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(false); + + // Local camera turns back on — auto-open fires again since there is + // nothing left to have dismissed. + mockVoiceStoreGetState.mockReturnValue({ ...state, localCamera: true }); + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(true); + }); + + it("explicit showVideoGrid clears a prior dismissal", () => { + const users = new Map([ + [1, { userId: 1, camera: false, screenshare: false, username: "me" }], + ]); + mockVoiceStoreGetState.mockReturnValue( + makeVoiceState({ + currentChannelId: 10, + localCamera: true, + voiceUsers: new Map([[10, users]]), + }), + ); + + const ctrl = createVideoModeController({ + slots: makeSlots(), + videoGrid: makeVideoGrid(), + getCurrentUserId: () => 1, + }); + + ctrl.checkVideoMode(); + ctrl.showChat(); + expect(ctrl.isVideoMode()).toBe(false); + + // User manually re-opens the grid. + ctrl.showVideoGrid(); + expect(ctrl.isVideoMode()).toBe(true); + + ctrl.showChat(); + ctrl.checkVideoMode(); + // Dismissal was cleared by showVideoGrid, but showChat() re-set it — + // so this exercises that the flag responds to the most recent call. + expect(ctrl.isVideoMode()).toBe(false); + }); + + it("does not treat leaving the channel as a dismissal (v048)", () => { + const users = new Map([ + [1, { userId: 1, camera: false, screenshare: false, username: "me" }], + ]); + const inChannel = makeVoiceState({ + currentChannelId: 10, + localCamera: true, + voiceUsers: new Map([[10, users]]), + }); + mockVoiceStoreGetState.mockReturnValue(inChannel); + + const ctrl = createVideoModeController({ + slots: makeSlots(), + videoGrid: makeVideoGrid(), + getCurrentUserId: () => 1, + }); + + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(true); + + // leaveVoice() clears currentChannelId before localCamera goes false, + // and this checkVideoMode() returns early — closing the grid here must + // not record a dismissal that outlives the session. + mockVoiceStoreGetState.mockReturnValue({ ...inChannel, currentChannelId: null }); + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(false); + + // Next session, camera on again: auto-open must still work. + mockVoiceStoreGetState.mockReturnValue(inChannel); + ctrl.checkVideoMode(); + expect(ctrl.isVideoMode()).toBe(true); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts index 181a1541..35fe2e71 100644 --- a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts +++ b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts @@ -130,6 +130,84 @@ describe("VoiceAudioTab camera preview", () => { }); }); +describe("VoiceAudioTab mic meter", () => { + let resolveAudio: ((stream: MediaStream) => void) | null = null; + const stopAudioTrack = vi.fn(); + const audioStream = { + getTracks: () => [{ stop: stopAudioTrack }], + } as unknown as MediaStream; + + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + resolveAudio = null; + stopAudioTrack.mockClear(); + + vi.stubGlobal( + "AudioContext", + class { + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + frequencyBinCount: 32, + getByteFrequencyData: vi.fn(), + }; + } + + createMediaStreamSource() { + return { connect: vi.fn() }; + } + + close() { + return Promise.resolve(); + } + }, + ); + + // No videoInputDevice pref, so only the mic meter requests media. + vi.stubGlobal("navigator", { + mediaDevices: { + enumerateDevices: vi.fn().mockResolvedValue([]), + getUserMedia: vi.fn().mockImplementation( + () => + new Promise<MediaStream>((resolve) => { + resolveAudio = resolve; + }), + ), + }, + }); + }); + + it("stops the mic stream when cleanup ran while getUserMedia was pending", async () => { + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + document.body.appendChild(tab.build()); + + // SettingsOverlay.hide() calls cleanup() without aborting; a getUserMedia + // resolving afterwards must not open the mic — nobody is left to stop it. + tab.cleanup(); + (resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream); + + await vi.waitFor(() => { + expect(stopAudioTrack).toHaveBeenCalledTimes(1); + }); + }); + + it("stops the mic stream when the tab was aborted while getUserMedia was pending", async () => { + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + document.body.appendChild(tab.build()); + + ac.abort(); + (resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream); + + await vi.waitFor(() => { + expect(stopAudioTrack).toHaveBeenCalledTimes(1); + }); + }); +}); + // --------------------------------------------------------------------------- // UI structure and interaction tests // --------------------------------------------------------------------------- @@ -669,6 +747,40 @@ describe("VoiceAudioTab UI structure", () => { ac.abort(); }); + it("stops applying sensitivity after a pointercancel interrupts the drag (v097)", () => { + stubNavigator(); + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + const el = tab.build(); + document.body.appendChild(el); + + const threshold = el.querySelector(".mic-meter-threshold") as HTMLElement; + expect(threshold).not.toBeNull(); + // jsdom doesn't implement the Pointer Capture API — stub it as a no-op, + // same as a real browser call the handler makes unconditionally. + (threshold as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = + vi.fn(); + + threshold.dispatchEvent(new PointerEvent("pointerdown", { pointerId: 1, clientX: 0 })); + mockSetVoiceSensitivity.mockClear(); + + threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 10 })); + expect(mockSetVoiceSensitivity).toHaveBeenCalledTimes(1); + + // The OS claims the touch gesture as a pan and fires pointercancel + // instead of pointerup. + threshold.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 1 })); + + mockSetVoiceSensitivity.mockClear(); + threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 50 })); + + // Without a pointercancel listener, onMove stays attached and this + // would call setVoiceSensitivity again with no button held. + expect(mockSetVoiceSensitivity).not.toHaveBeenCalled(); + + ac.abort(); + }); + it("mic level monitoring handles getUserMedia failure gracefully", async () => { vi.stubGlobal("navigator", { mediaDevices: { diff --git a/Client/tauri-client/tests/unit/voice-disconnect.test.ts b/Client/tauri-client/tests/unit/voice-disconnect.test.ts index 8fcb4a7a..2f2597f3 100644 --- a/Client/tauri-client/tests/unit/voice-disconnect.test.ts +++ b/Client/tauri-client/tests/unit/voice-disconnect.test.ts @@ -6,12 +6,19 @@ */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "../../src/stores/voice.store"; -import { authStore } from "../../src/stores/auth.store"; +import { authStore, clearAuth } from "../../src/stores/auth.store"; import { channelsStore } from "../../src/stores/channels.store"; import { membersStore } from "../../src/stores/members.store"; import { uiStore } from "../../src/stores/ui.store"; import { createVoiceWidget } from "../../src/components/VoiceWidget"; +// clearAuth (used below to test the real logoutWasInVoice ordering fix) +// lazily imports livekitSession when a voice session is active — mock it so +// that stays a fire-and-forget call instead of pulling in the real SDK. +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), +})); + function resetStores(): void { voiceStore.setState(() => ({ currentChannelId: null, @@ -150,6 +157,34 @@ describe("Voice disconnect — logout cleanup", () => { expect(voiceStore.getState().currentChannelId).toBeNull(); }); + it("gates voice_leave on clearAuth's logoutWasInVoice snapshot, not the already-reset voiceStore", () => { + const wsSend = vi.fn(); + + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "testuser", avatar: null, role: "member" }, + isAuthenticated: true, + })); + joinVoiceChannel(42); + + // Real clearAuth (auth.store.ts) — applies state (including the + // isAuthenticated flip) synchronously and resets voiceStore in the same + // call, before main.ts's isAuthenticated subscriber ever runs (store + // notifications are microtask-deferred). By the time such a subscriber + // fires, voiceStore.getState().currentChannelId is already null. + clearAuth(); + + // Simulate main.ts's subscriber body (post-fix): gates on the snapshot + // clearAuth left on authStore instead of re-reading voiceStore. + if (authStore.getState().logoutWasInVoice === true) { + wsSend({ type: "voice_leave", payload: {} }); + } + + expect(wsSend).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + // Proves the race is real: voiceStore was already reset by clearAuth. + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + it("does not send voice_leave on logout when not in voice channel", () => { const wsSend = vi.fn(); const wsDisconnect = vi.fn(); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index b74e7daa..635d6280 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -9,6 +9,7 @@ import { leaveVoiceChannel, setLocalMuted, setLocalDeafened, + setPttGated, setLocalCamera, setLocalScreenshare, setListenOnly, @@ -124,6 +125,15 @@ describe("voice store", () => { expect(user?.screenshare).toBe(false); }); + it("maps camera/screenshare from the ready payload when present", () => { + // The server marshals the voice_states rows wholesale — a full-ready + // resync mid-call must not blank a peer's active camera/screenshare. + setVoiceStates([{ ...VOICE_STATE_1, camera: true, screenshare: true }]); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.camera).toBe(true); + expect(user?.screenshare).toBe(true); + }); + it("replaces existing voice states entirely", () => { setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); setVoiceStates([VOICE_STATE_3]); @@ -132,6 +142,59 @@ describe("voice store", () => { expect(state.voiceUsers.has(10)).toBe(false); expect(state.voiceUsers.has(20)).toBe(true); }); + + describe("local moderator-flag derivation (v049)", () => { + afterEach(() => { + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: "", + isAuthenticated: false, + })); + }); + + it("derives localServerMuted/localServerDeafened from the signed-in user's row", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "me", avatar: null, role: "member" }, + })); + // A full-ready reconnect (mustFullResync / replay miss) that + // preserves a live voice session reports the moderator flags on the + // signed-in user's own row. + setVoiceStates([ + { ...VOICE_STATE_1, server_muted: true, server_deafened: true }, + VOICE_STATE_2, + ]); + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(true); + expect(state.localServerDeafened).toBe(true); + }); + + it("resets the local moderator flags to false when the signed-in user's row omits them", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "me", avatar: null, role: "member" }, + })); + // Simulate a stale localServerMuted from before the resync. + voiceStore.setState((prev) => ({ ...prev, localServerMuted: true })); + setVoiceStates([VOICE_STATE_1]); // no server_muted/server_deafened on this row + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(false); + expect(state.localServerDeafened).toBe(false); + }); + + it("leaves the local moderator flags false when the signed-in user is absent from the payload", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 999, username: "me", avatar: null, role: "member" }, + })); + setVoiceStates([{ ...VOICE_STATE_1, server_muted: true }]); + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(false); + expect(state.localServerDeafened).toBe(false); + }); + }); }); describe("updateVoiceState", () => { @@ -291,6 +354,26 @@ describe("voice store", () => { }); }); + describe("setPttGated", () => { + it("sets pttGated to true", () => { + setPttGated(true); + expect(voiceStore.getState().pttGated).toBe(true); + }); + + it("sets pttGated to false", () => { + setPttGated(true); + setPttGated(false); + expect(voiceStore.getState().pttGated).toBe(false); + }); + + it("does not touch localMuted — PTT must never write the explicit-mute flag (v006)", () => { + setLocalMuted(true); + setPttGated(false); // PTT key pressed + expect(voiceStore.getState().localMuted).toBe(true); + expect(voiceStore.getState().pttGated).toBe(false); + }); + }); + describe("setLocalCamera / setLocalScreenshare", () => { it("setLocalCamera sets camera to true", () => { setLocalCamera(true); diff --git a/Client/tauri-client/tests/unit/ws-cert.test.ts b/Client/tauri-client/tests/unit/ws-cert.test.ts index 0351641e..ee742987 100644 --- a/Client/tauri-client/tests/unit/ws-cert.test.ts +++ b/Client/tauri-client/tests/unit/ws-cert.test.ts @@ -68,6 +68,47 @@ describe("cert mismatch blocking", () => { expect(reconnectCalls).toHaveLength(0); }); + it("blocks reconnect when the profile host carries an explicit :443 that the Rust proxy normalizes away", async () => { + // Regression for v052: config.host is stored verbatim (e.g. a profile + // saved as "example.com:443"), but the Rust proxies emit the event host + // through tofu::cert_store_key, which strips a trailing ":443". An + // un-normalized comparison would miss this match and the reconnect loop + // would keep re-handshaking the untrusted host every backoff interval. + client.connect({ host: "example.com:443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Rust-normalized event host — no ":443" suffix. + emitTauriEvent("cert-tofu", { + host: "example.com", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + expect(client.getState()).toBe("disconnected"); + + emitTauriEvent("ws-state", "closed"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnectCalls).toHaveLength(0); + }); + it("should unblock after acceptCertFingerprint", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); @@ -185,6 +226,172 @@ describe("cert mismatch blocking", () => { const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnects).toHaveLength(0); }); + it("does not latch or drop state on a mismatch for a different (unrelated) host", async () => { + const mismatchEvents: unknown[] = []; + client.onCertMismatch((evt) => mismatchEvents.push(evt)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + expect(client.getState()).toBe("connected"); + + // A rotated cert on a DIFFERENT saved profile (e.g. the connect page's + // 15s health-check loop probing another server) must not touch this + // connection at all. + emitTauriEvent("cert-tofu", { + host: "other.example:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + // Still notified — so a connect-page prompt for that OTHER host works... + expect(mismatchEvents).toHaveLength(1); + // ...but this unrelated connection must not be latched or disconnected. + expect(client.getState()).toBe("connected"); + + // And it must keep reconnecting normally after a later drop. + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(2000); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnectCalls.length).toBeGreaterThan(0); + }); + + it("connect() resets certMismatchBlock even when not preceded by disconnect()", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + }); + expect(client.getState()).toBe("disconnected"); + + // A fresh connect() call — not preceded by disconnect() or + // acceptCertFingerprint() (e.g. the suppressed-modal path where a second + // host's mismatch latched the flag while a first-use modal was open, and + // the user logs in anyway) — must clear the stale latch itself. + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent("ws-state", "closed"); // drop again, still unauthenticated + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(2000); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnectCalls.length).toBeGreaterThan(0); + }); + + it("a mismatch latched while a reconnect is already pending cannot be bypassed by that timer", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Socket drops first: a reconnect timer is now armed and counting down. + emitTauriEvent("ws-state", "closed"); + expect(client.getState()).toBe("reconnecting"); + + // The mismatch arrives DURING that backoff (e.g. the connect page's + // 15s health check re-probing this same host). Latching the flag is not + // enough on its own — the already-armed timer still fires connect(), + // which clears the latch, so the reconnect loop resumes against a host + // whose certificate just changed. The latch must cancel it. + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + }); + expect(client.getState()).toBe("disconnected"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(10_000); + const bypassed = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(bypassed).toHaveLength(0); + }); +}); + +describe("disconnect() resets reconnectAttempt", () => { + let client: ReturnType<typeof createWsClient>; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("resets the backoff exponent so a later session's first retry uses the short delay", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // One drop before auth_ok, letting the scheduled retry fire — grows + // reconnectAttempt to 1 (the next backoff would double to 2s). + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1000); + emitTauriEvent("ws-state", "open"); + + // Abandon this session mid-backoff (reconnectAttempt is now 1). + client.disconnect(); + + // A brand new session — its first handshake also drops before auth_ok. + mockInvoke.mockClear(); + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent("ws-state", "closed"); + + // If reconnectAttempt carried over (still 1) the next backoff would be + // 2000ms; reset to 0 it is the base 1000ms — so a reconnect must have + // fired by exactly 1000ms. + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1000); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnectCalls.length).toBeGreaterThan(0); + }); }); describe("parseStoredFingerprint", () => { diff --git a/Client/tauri-client/tsconfig.e2e.json b/Client/tauri-client/tsconfig.e2e.json new file mode 100644 index 00000000..0936137f --- /dev/null +++ b/Client/tauri-client/tsconfig.e2e.json @@ -0,0 +1,15 @@ +{ + // Typechecks the Playwright layer (47 spec files + fixtures + the three + // playwright configs), which the main tsconfig deliberately excludes from + // the app graph. "exclude": [] is required to clear the inherited + // "tests/e2e" exclusion — same trick tsconfig.build.json uses. + "extends": "./tsconfig.json", + "include": [ + "tests/e2e", + "playwright.config.ts", + "playwright.config.prod.ts", + "playwright.config.native.ts", + "playwright.config.admin.ts" + ], + "exclude": [] +} diff --git a/Client/tauri-client/vite.config.ts b/Client/tauri-client/vite.config.ts index 81bf0676..4bc11bbd 100644 --- a/Client/tauri-client/vite.config.ts +++ b/Client/tauri-client/vite.config.ts @@ -42,5 +42,13 @@ export default defineConfig({ strictPort: true, host: host || false, hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, + watch: { + // Never watch the Rust tree. `tauri dev` runs Vite as its + // `beforeDevCommand`, so without this the watcher picks up + // `src-tauri/target/` and dies with EBUSY the moment cargo writes the + // output DLL on Windows — taking the whole dev session with it. Tauri + // already watches `src-tauri` itself for rebuilds. + ignored: ["**/src-tauri/**"], + }, }, }); diff --git a/README.md b/README.md index 27d715b7..0ccbd511 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ That keeps iteration fast, and it also means behaviour can change quickly betwee | Core chat flow | Working in alpha | | Voice/video | Working in alpha | | Admin panel | Working in alpha | -| Security hardening | First full review pass landed in v1.1.0-alpha.2; further review passes since; ongoing | +| Security hardening | Ongoing review passes; findings and their statuses are tracked in the dated audits in [docs/](docs/) (see the Docs Index below) | ## Platform Support (Current Releases) @@ -90,6 +90,8 @@ The client uses TOFU (Trust On First Use) for self-signed certificates: it promp - File uploads and inline media rendering - TOTP 2FA support and API rate limiting - Desktop client auto-update with signature verification +- WASM plugin system (slash commands; sandboxed, default-disabled — enable via + `plugins.enabled` and build with `-tags wazero`) - GIF picker — off by default; each server supplies its own [Klipy](https://partner.klipy.com) key via `gif.api_key` ([setup](docs/server-configuration.md#gif-picker-gif)) @@ -136,11 +138,11 @@ Two main components: ```bash # Server (Windows) cd Server -go build -o chatserver.exe -ldflags "-s -w -X main.version=1.1.0-alpha.3" . +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.1" . # Server (Linux) cd Server -CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.1.0-alpha.3" . +CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.1" . # Client cd Client/tauri-client @@ -206,20 +208,28 @@ When rotating the server updater key, update [Server/updater/server_update_publi - [docs/port-forwarding.md](docs/port-forwarding.md) - [docs/tailscale.md](docs/tailscale.md) - [docs/architecture/](docs/architecture/README.md) — system blueprints (diagrams + flows) -- [docs/audit-2026-07-19.md](docs/audit-2026-07-19.md) — latest architecture & spec-conformance audit +- [docs/audit-2026-08-04-docs-and-coverage.md](docs/audit-2026-08-04-docs-and-coverage.md) — latest full audit (docs accuracy, UX flow coverage, test runs) +- [docs/audit-2026-08-04.md](docs/audit-2026-08-04.md) — latest security review +- [docs/audit-2026-07-19.md](docs/audit-2026-07-19.md) — architecture & spec-conformance audit - [docs/api.md](docs/api.md) - [docs/protocol.md](docs/protocol.md) - [docs/schema.md](docs/schema.md) - [docs/architecture/client.md](docs/architecture/client.md) — client architecture (replaces client-architecture.md) - [docs/architecture/ux/](docs/architecture/ux/README.md) — client UX specification (target-state flows, per-view states, event→reaction maps) +- [docs/server-configuration.md](docs/server-configuration.md) +- [docs/credential-storage.md](docs/credential-storage.md) +- [docs/mcp-introspect.md](docs/mcp-introspect.md) — dev-only MCP server for introspecting a running instance +- [docs/audit-test-coverage-2026-07-25.md](docs/audit-test-coverage-2026-07-25.md) — test-coverage audit +- [docs/audit-2026-04-07.md](docs/audit-2026-04-07.md) — first comprehensive audit +- [docs/plans/](docs/plans/) — design plans and decision records (each carries a verified status header) - [docs/contributing.md](docs/contributing.md) - [docs/security.md](docs/security.md) ## Contributing -1. Create a branch from `main`. +1. Create a branch from `dev` (the active development branch). 2. Keep changes focused and tested. -3. Open a PR targeting `main`. +3. Open a PR targeting `dev` — `dev` is merged to `main` for releases. See [docs/contributing.md](docs/contributing.md) for the full process. diff --git a/Server/CLAUDE.md b/Server/CLAUDE.md new file mode 100644 index 00000000..802586d8 --- /dev/null +++ b/Server/CLAUDE.md @@ -0,0 +1,26 @@ +# OwnCord Server (Go) + +Go 1.26, module `github.com/owncord/server`. Key deps: chi (HTTP), koanf +(config), sqlc-generated SQLite layer, LiveKit server SDK, coraza WAF, +prometheus. + +## Layout + +- `api/` REST handlers · `ws/` WebSocket hub · `auth/` sessions/TOTP · + `permissions/` role checks · `service/` domain logic shared by both entry points +- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`) +- `admin/` web admin panel · `updater/` self-update + signature verification · + `plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`) +- `syncutil/` lock helpers that gain deadlock detection under `-tags deadlock` + +## Gotchas + +- Build tags gate whole files, so all four variants must compile: default, + `-tags otel`, `-tags wazero`, `-tags otel,wazero`. Tests must also pass under + `-race` and under `-tags deadlock`. The `ci-check` skill has the commands. +- `ws` is the hub: broadcast fan-out, per-client send queues, replay, and voice + state all interact under several locks. Sequenced frames share one per-client + FIFO because clients ack only `max(seq)` — a frame that skips the queue, or a + seq allocated for a frame that is then dropped, is silently unrecoverable. +- Prefer the standard library. `syncutil` exists so lock usage is uniform and + detectable; do not hand-roll around it. diff --git a/Server/Dockerfile b/Server/Dockerfile index 3f2e3277..3ad527b1 100644 --- a/Server/Dockerfile +++ b/Server/Dockerfile @@ -31,6 +31,10 @@ VOLUME ["/app/data"] # Server listens on this port by default (configurable via config.yaml). EXPOSE 8443 +# Refuses the in-place self-update endpoint (the binary is image content; +# upgrades are image pulls). See Server/updater/container.go. +ENV OWNCORD_CONTAINER=1 + # Run as non-root (distroless provides uid 65532 = "nonroot"). USER 65532:65532 diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index c1863002..a979fa88 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -651,6 +651,64 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { } } +// Deleting a channel must evict its voice participants BEFORE the DB row goes +// away: the voice_states FK cascade wipes the rows with the channel, after +// which neither CleanupVoiceForChannel nor the stale sweeper can see who was +// in the room — participants would keep their client voice state, voice-topic +// subscription, and LiveKit session forever, with no voice_leave broadcast. +func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + // The minimal admin schema has no voice_states; create it with the real + // FK cascade — the cascade IS the hazard: it wipes the rows the cleanup + // needs if the delete runs first. + if _, err := database.ExecContext(context.Background(), ` + CREATE TABLE voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened INTEGER NOT NULL DEFAULT 0 + )`); err != nil { + t.Fatalf("create voice_states: %v", err) + } + + chID, _ := database.AdminCreateChannel(context.Background(), "del-voice", "voice", "", "", 0) + uid, _ := database.CreateUser(context.Background(), "del-voice-user", "hash", 1) + if err := database.JoinVoiceChannel(context.Background(), uid, chID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + rowsAtCleanup := -1 + hub.onVoiceCleanup = func(channelID int64) { + states, err := database.GetChannelVoiceStates(context.Background(), channelID) + if err != nil { + t.Errorf("GetChannelVoiceStates during cleanup: %v", err) + } + rowsAtCleanup = len(states) + } + + w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + + if len(hub.voiceCleanupIDs) != 1 || hub.voiceCleanupIDs[0] != chID { + t.Fatalf("CleanupVoiceForChannel calls = %v, want exactly [%d]", hub.voiceCleanupIDs, chID) + } + if rowsAtCleanup != 1 { + t.Errorf("voice_states rows visible at cleanup time = %d, want 1 (cleanup must run before the delete cascade)", rowsAtCleanup) + } +} + func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) @@ -1171,6 +1229,10 @@ type mockHub struct { allVisibilityRefreshes int rolesUpdates [][]*db.Role clientCount int + voiceCleanupIDs []int64 + // onVoiceCleanup lets a test observe DB state at cleanup time (ordering + // vs. the channel-delete cascade). + onVoiceCleanup func(channelID int64) } type memberUpdateCall struct { @@ -1199,6 +1261,13 @@ func (m *mockHub) BroadcastChannelDelete(channelID int64) { m.channelDeleteIDs = append(m.channelDeleteIDs, channelID) } +func (m *mockHub) CleanupVoiceForChannel(channelID int64) { + m.voiceCleanupIDs = append(m.voiceCleanupIDs, channelID) + if m.onVoiceCleanup != nil { + m.onVoiceCleanup(channelID) + } +} + func (m *mockHub) BroadcastMemberBan(userID int64) { m.memberBanIDs = append(m.memberBanIDs, userID) } diff --git a/Server/admin/channels_archive_voice_test.go b/Server/admin/channels_archive_voice_test.go new file mode 100644 index 00000000..fa65639d --- /dev/null +++ b/Server/admin/channels_archive_voice_test.go @@ -0,0 +1,65 @@ +package admin_test + +import ( + "context" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/db" +) + +// Archiving a voice channel hides it from every client the same way deleting +// it does, so live voice participants must be evicted the same way +// handleDeleteChannel evicts them (v036) — otherwise they keep their +// voice_states row, VoiceTopic subscription and LiveKit session in a room +// nothing shows any more. +func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + chID, _ := database.AdminCreateChannel(context.Background(), "archive-voice", "voice", "", "", 0) + + w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, map[string]any{ + "archived": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + if len(hub.voiceCleanupIDs) != 1 || hub.voiceCleanupIDs[0] != chID { + t.Fatalf("CleanupVoiceForChannel calls = %v, want exactly [%d]", hub.voiceCleanupIDs, chID) + } + if len(hub.visibilityRefreshes) != 1 { + t.Fatalf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes)) + } +} + +// Unarchiving must not run the voice cleanup — only the true->true and +// false->true transition (going *into* archived) evicts anyone; a channel +// coming back out of the archive has no live participants to evict and +// should not falsely report a cleanup call. +func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + chID, _ := database.AdminCreateChannel(context.Background(), "unarchive-voice", "voice", "", "", 0) + if err := database.AdminUpdateChannel(context.Background(), chID, db.ChannelUpdate{Archived: true}); err != nil { + t.Fatalf("seed archived channel: %v", err) + } + + w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, map[string]any{ + "archived": false, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + if len(hub.voiceCleanupIDs) != 0 { + t.Fatalf("CleanupVoiceForChannel calls = %v, want none on unarchive", hub.voiceCleanupIDs) + } +} diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 16624d50..ab0f7780 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -176,6 +176,19 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { dbPath := filepath.Join("data", "chatserver.db") + actor := actorFromContext(r) + // Audit the restore BEFORE the pre-restore safety copy is taken, and + // synchronously (LogAudit, not WriteAudit): the restore overwrites the + // live database file, so the only durable home for this row is the + // pre_restore_* backup captured below — an entry enqueued on the async + // WriteAudit path could still be sitting in the writer's buffer when + // BackupTo snapshots the DB. Best-effort per policy D8: a failed write + // is logged, never a reason to refuse the restore. + if err := database.LogAudit(context.WithoutCancel(r.Context()), actor, "backup_restore", "server", 0, + fmt.Sprintf("restoring backup %s", name)); err != nil { + slog.Error("audit log write failed", "action", "backup_restore", "actor_id", actor, "error", err) + } + // Safety: create a pre-restore backup before overwriting. WithoutCancel: // the restore proceeds regardless of client disconnect (Close/copyFile // below are not ctx-aware), so the safety backup must not be skippable @@ -205,7 +218,6 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { slog.Warn("pre-restore WAL checkpoint failed", "err", checkpointErr) } - actor := actorFromContext(r) slog.Warn("database restored from backup — closing DB", "actor_id", actor, "backup", name) if err := database.Close(); err != nil { @@ -217,7 +229,23 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { // Stream the backup file over the (now closed) database to avoid loading // the entire DB into memory (could be hundreds of MiB). if err := copyFile(target, dbPath); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to restore database file") + // copyFile truncates the destination with os.Create before it can know + // whether the read will succeed, so the live database file is already + // destroyed by the time we get here — and the DB is closed, so nothing + // is holding the old contents. Put the safety copy back rather than + // leaving the operator with a zero-byte database. + slog.Error("restore copy failed — rolling back to the pre-restore safety copy", "backup", name, "err", err) + msg := "failed to restore database file — the pre-restore safety copy was put back, server restarting" + if rbErr := copyFile(preRestore, dbPath); rbErr != nil { + slog.Error("rollback from the pre-restore safety copy failed — recover manually", + "safety_copy", preRestore, "err", rbErr) + msg = "failed to restore database file AND failed to roll back — recover manually from " + filepath.Base(preRestore) + } + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", msg) + // The database was closed before the copy: this process cannot serve + // anything more either way, so it must respawn exactly as it does on + // the success path. + go requestRestart("backup_restore_failed") return } diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 3d0d594c..1afb53ee 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -12,6 +12,7 @@ import ( "github.com/owncord/server/admin" "github.com/owncord/server/auth" + "github.com/owncord/server/db" ) // chdirTemp changes the working directory to a fresh temp directory for the @@ -308,14 +309,114 @@ func TestHandleRestoreBackup_Success(t *testing.T) { if err != nil { t.Fatalf("ReadDir backups: %v", err) } - found := false + preRestore := "" for _, e := range entries { if strings.HasPrefix(e.Name(), "pre_restore_") { - found = true + preRestore = filepath.Join(backupDir, e.Name()) } } - if !found { - t.Error("no pre_restore_*.db safety backup was created") + if preRestore == "" { + t.Fatal("no pre_restore_*.db safety backup was created") + } + + // The backup_restore audit row must be INSIDE the safety copy — the live + // DB file is replaced by the restore, so the pre_restore backup is that + // row's only durable home. Asserting against the reopened backup file (not + // the handler's DB, which is closed by now) proves both the write and its + // ordering before BackupTo. + restoredDB, err := db.Open(preRestore) + if err != nil { + t.Fatalf("db.Open(pre-restore backup): %v", err) + } + defer restoredDB.Close() //nolint:errcheck + audits, err := restoredDB.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog on pre-restore backup: %v", err) + } + foundAudit := false + for _, e := range audits { + if e.Action == "backup_restore" { + foundAudit = true + } + } + if !foundAudit { + t.Error("expected a backup_restore audit entry inside the pre-restore safety backup") + } +} + +// TestHandleRestoreBackup_RollsBackWhenCopyFails verifies the live database file +// is not left destroyed when the copy fails partway. copyFile truncates the live +// DB with os.Create before it can know whether the read will succeed, so a +// failure there leaves a closed DB and a zero-byte file underneath it; the +// pre-restore safety copy must be put back, and the process must still respawn +// because the DB is closed either way. +// +// The failure is injected by making the "backup" a directory: it passes the +// handler's existence check and opens, but reading it fails after the truncate. +func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) { + tmpDir := chdirTemp(t) + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + backupDir := filepath.Join(tmpDir, "data", "backups") + if err := os.MkdirAll(backupDir, 0o750); err != nil { + t.Fatalf("MkdirAll backups: %v", err) + } + dbPath := filepath.Join(tmpDir, "data", "chatserver.db") + if err := os.WriteFile(dbPath, []byte("live database contents"), 0o600); err != nil { + t.Fatalf("WriteFile live db: %v", err) + } + + backupName := "chatserver_20240102_120000.db" + if err := os.MkdirAll(filepath.Join(backupDir, backupName), 0o750); err != nil { + t.Fatalf("MkdirAll fake backup: %v", err) + } + + restarted, restoreHook := admin.StubRestart() + defer restoreHook() + + w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String()) + } + + entries, err := os.ReadDir(backupDir) + if err != nil { + t.Fatalf("ReadDir backups: %v", err) + } + preRestore := "" + for _, e := range entries { + if strings.HasPrefix(e.Name(), "pre_restore_") { + preRestore = filepath.Join(backupDir, e.Name()) + } + } + if preRestore == "" { + t.Fatal("no pre_restore_*.db safety backup was created") + } + want, err := os.Stat(preRestore) + if err != nil { + t.Fatalf("Stat pre-restore backup: %v", err) + } + + got, err := os.Stat(dbPath) + if err != nil { + t.Fatalf("Stat live db after failed restore: %v", err) + } + if got.Size() == 0 { + t.Error("live database file was left truncated after the failed restore") + } + if got.Size() != want.Size() { + t.Errorf("live db size = %d, want %d (the safety copy should have been put back)", got.Size(), want.Size()) + } + + deadline := time.Now().Add(2 * time.Second) + for !restarted() && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if !restarted() { + t.Error("failed restore did not request a process restart, but the database is closed") } } diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go index f5b48f66..b7c25e5a 100644 --- a/Server/admin/handlers_channel_perms.go +++ b/Server/admin/handlers_channel_perms.go @@ -188,6 +188,29 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") return } + role, err := database.GetRoleByID(r.Context(), roleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role") + return + } + if role == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found") + return + } + + actorRole := actorRoleFromContext(r) + if actorRole == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + // Hierarchy guard: deleting an override is a permission mutation with the + // same authority as writing one (removing a deny row restores exactly the + // access the PUT path refuses to grant), so gate it identically to + // handlePutChannelPermission. + if role.Position >= actorRole.Position { + writeErr(w, http.StatusForbidden, "FORBIDDEN", "cannot manage a role at or above your own rank") + return + } if err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission") @@ -197,7 +220,7 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva actor := actorFromContext(r) slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID) db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_perms_clear", "channel", ch.ID, - fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name)) + fmt.Sprintf("cleared overrides for role %s on #%s", role.Name, ch.Name)) if permInvalidator != nil { permInvalidator.InvalidateAll() diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index 8a36d41d..6f70cce5 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -345,3 +345,68 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { t.Errorf("second delete status = %d, want 204", w.Code) } } + +// Deleting an override is a permission mutation: removing a deny row restores +// exactly the access the PUT path refuses to grant. The DELETE handler must +// therefore refuse targets at or above the actor's own position, mirroring +// TestPutChannelPermission_RefusesEqualOrHigherRole (A-2026-08-01). +func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + + chID, err := database.CreateChannel(context.Background(), "hierarchy-del", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + cases := []struct { + name string + roleID int64 + }{ + {"higher role (Admin, position 80)", 2}, + {"own role (Moderator, position 60)", 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Seed the override the attacker wants gone (e.g. the deny that + // keeps a private channel hidden from their role). + if err := database.UpsertChannelOverride(context.Background(), chID, tc.roleID, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/"+itoa(tc.roleID), modToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, tc.roleID) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != permissions.ReadMessages { + t.Errorf("override mutated by forbidden delete: (%#x, %#x)", allow, deny) + } + }) + } +} + +// A missing role must 404 before any deletion happens, matching the PUT twin +// (TestPutChannelPermission_UnknownRole). +func TestDeleteChannelPermission_UnknownRole(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel(context.Background(), "hierarchy-del-404", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/999", token, nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } +} diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index 428fdba0..b7897e73 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "net/http" "slices" "strings" @@ -35,6 +36,32 @@ func validateChannelType(channelType string) string { // ─── Channel Handlers ──────────────────────────────────────────────────────── +// getAdminChannel loads the channel targeted by an admin channel mutation and +// writes the error response when it is missing — or when it is a DM. DMs and +// group DMs share the channels table and id space with guild channels, but +// they belong to their participants, not to MANAGE_CHANNELS holders: listing, +// renaming or deleting one from the admin surface would leak or destroy a +// private conversation (A-2026-08-02). A DM id answers 404 rather than 403 so +// the surface does not confirm which ids are private conversations. Returns +// nil when a response has already been written. +func getAdminChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel { + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") + return nil + } + ch, err := database.GetChannel(r.Context(), id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") + return nil + } + if ch == nil || ch.Type == "dm" { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found") + return nil + } + return ch +} + func handleListChannels(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { channels, err := database.ListChannels(r.Context()) @@ -42,7 +69,18 @@ func handleListChannels(database *db.DB) http.HandlerFunc { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels") return } - writeJSON(w, http.StatusOK, channels) + // The admin surface manages guild channels. DM rows live in the same + // table but are private conversations — enumerating them here exposed + // ids and user-chosen group names to any MANAGE_CHANNELS holder + // (A-2026-08-02). Filtered in Go because the sqlc ListChannels query is + // shared with the ready path, which applies its own visibility rules. + guildChannels := make([]db.Channel, 0, len(channels)) + for i := range channels { + if channels[i].Type != "dm" { + guildChannels = append(guildChannels, channels[i]) + } + } + writeJSON(w, http.StatusOK, guildChannels) } } @@ -158,21 +196,11 @@ func nsfwAuditSuffix(before, after bool) string { func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, err := pathInt64(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") - return - } - - existing, err := database.GetChannel(r.Context(), id) - if err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") - return - } + existing := getAdminChannel(database, w, r) if existing == nil { - writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found") return } + id := existing.ID // Start from existing values so a partial body is safe. req := updateChannelRequest{ @@ -227,6 +255,17 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { // metadata — send targeted channel_create/channel_delete so // connected clients re-sync without a reconnect. if existing.Archived != updated.Archived { + // Archiving hides a voice channel the same way deleting it + // does — nobody can see it or reach it afterward — so live + // participants must be evicted the same way handleDeleteChannel + // evicts them, or they keep their DB row, VoiceTopic + // subscription and LiveKit session in a room nothing shows. + // Order matches handleDeleteChannel: evict before the + // visibility change so a voice_leave lands on clients that + // still have the channel subscribed. + if !existing.Archived && updated.Archived { + hub.CleanupVoiceForChannel(id) + } hub.RefreshChannelVisibility(updated) } } @@ -236,21 +275,18 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, err := pathInt64(r, "id") - if err != nil { - writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") - return - } - - existing, err := database.GetChannel(r.Context(), id) - if err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") - return - } + existing := getAdminChannel(database, w, r) if existing == nil { - writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found") return } + id := existing.ID + + // Evict voice participants BEFORE deleting the row: the voice_states + // FK cascade wipes the rows the cleanup reads, and the stale sweeper + // cannot recover participants of a channel that no longer exists. + if hub != nil { + hub.CleanupVoiceForChannel(id) + } if err := database.AdminDeleteChannel(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel") @@ -269,8 +305,8 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { func handleGetAuditLog(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - limit := queryInt(r, "limit", 50, 1) - offset := queryInt(r, "offset", 0, 0) + limit := queryInt(r, "limit", 50, 1, 500) + offset := queryInt(r, "offset", 0, 0, math.MaxInt32) entries, err := database.GetAuditLog(r.Context(), limit, offset) if err != nil { diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index dd461851..f7e21fc1 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -378,3 +379,91 @@ func TestPatchChannel_FeatureFlagsRequireManageChannels(t *testing.T) { t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String()) } } + +// ─── DM exclusion (A-2026-08-02) ───────────────────────────────────────────── +// +// DMs and group DMs share the channels table and id space with guild channels, +// but they belong to their participants. The admin channel surface must not +// enumerate them (membership-graph oracle), rename them, or cascade-delete +// them. Mutations answer 404 rather than 403 so the surface does not confirm +// which ids are private conversations. + +func TestListChannels_ExcludesDMs(t *testing.T) { + handler, token, database := newChannelTestAPI(t) + + textID := newChannel(t, handler, token, "general", "text") + dmID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel dm: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, "/channels", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var channels []struct { + ID int64 `json:"id"` + Type string `json:"type"` + } + if err := json.Unmarshal(w.Body.Bytes(), &channels); err != nil { + t.Fatalf("unmarshal: %v", err) + } + sawText := false + for _, ch := range channels { + if ch.ID == dmID || ch.Type == "dm" { + t.Errorf("DM channel %d leaked into admin channel list", ch.ID) + } + if ch.ID == textID { + sawText = true + } + } + if !sawText { + t.Errorf("guild channel %d missing from admin channel list", textID) + } +} + +func TestPatchChannel_RefusesDM(t *testing.T) { + handler, token, database := newChannelTestAPI(t) + + dmID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel dm: %v", err) + } + + w := doRequest(t, handler, http.MethodPatch, fmt.Sprintf("/channels/%d", dmID), token, map[string]any{ + "name": "renamed-by-admin", + }) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } + + ch, err := database.GetChannel(context.Background(), dmID) + if err != nil || ch == nil { + t.Fatalf("GetChannel after refused patch: ch=%v err=%v", ch, err) + } + if ch.Name != "dm-chan" { + t.Errorf("DM renamed by refused patch: %q", ch.Name) + } +} + +func TestDeleteChannel_RefusesDM(t *testing.T) { + handler, token, database := newChannelTestAPI(t) + + dmID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel dm: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, fmt.Sprintf("/channels/%d", dmID), token, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } + + ch, err := database.GetChannel(context.Background(), dmID) + if err != nil { + t.Fatalf("GetChannel after refused delete: %v", err) + } + if ch == nil { + t.Error("DM channel destroyed by refused delete") + } +} diff --git a/Server/admin/handlers_roles.go b/Server/admin/handlers_roles.go index 9a206c0d..d892b473 100644 --- a/Server/admin/handlers_roles.go +++ b/Server/admin/handlers_roles.go @@ -131,7 +131,12 @@ func handlePatchRole(database *db.DB, hub HubBroadcaster, permInvalidator Permis // but reading first keeps the invalidation correct even if a concurrent // role assignment lands in between (the extra id is a wasted eviction, // a missing one is a stale grant). - affected := roles.AffectedUserIDs(r.Context(), id) + affected, affectedOK := roles.AffectedUserIDs(r.Context(), id) + + // The pre-update name is read the same way, so a rename can be + // detected below even though UpdateRole itself only reports whether + // permissions changed. + before, beforeErr := database.GetRoleByID(r.Context(), id) role, permsChanged, err := roles.UpdateRole(r.Context(), actorFromContext(r), id, req.toInput()) if err != nil { @@ -139,8 +144,28 @@ func handlePatchRole(database *db.DB, hub HubBroadcaster, permInvalidator Permis return } + // Clients key a member's role by NAME, not id (member_update / + // ready carry a role name string). A rename alone leaves every + // member of the role holding a name that no longer resolves against + // the post-rename role list — they lose their role color, member-list + // group and permission-gated affordances until they reconnect. A + // member_update per affected user re-keys them, exactly like a role + // delete's fallback move already does. + if beforeErr == nil && before != nil && before.Name != role.Name && affectedOK && hub != nil { + for _, uid := range affected { + hub.BroadcastMemberUpdate(uid, role.Name) + } + } + if permsChanged { - invalidateUsers(permInvalidator, affected) + if affectedOK { + invalidateUsers(permInvalidator, affected) + } else if permInvalidator != nil { + // The member list was unreadable, so per-user eviction cannot + // be trusted; drop every cached mask instead (what reorder + // does on every call) rather than leave revoked grants live. + permInvalidator.InvalidateAll() + } // READ_MESSAGES may have moved in either direction, so every // channel's audience for this role has to be re-derived. if hub != nil { diff --git a/Server/admin/handlers_roles_rename_test.go b/Server/admin/handlers_roles_rename_test.go new file mode 100644 index 00000000..6e98aa90 --- /dev/null +++ b/Server/admin/handlers_roles_rename_test.go @@ -0,0 +1,55 @@ +package admin_test + +import ( + "net/http" + "testing" +) + +// Clients key a member's role by name, not id — a name-only PATCH leaves +// every member of the role holding a name that no longer resolves against +// the post-rename role list until a member_update re-keys them (v040). +func TestAdminAPI_PatchRole_RenameBroadcastsMemberUpdate(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, _, token := newRolesHandler(t, database) + + // role id 3 is the seeded "Member" role; give it a holder before renaming. + createUserWithRole(t, database, "renametest", 3) + + w := doRequest(t, handler, http.MethodPatch, "/roles/3", token, map[string]any{ + "name": "Mods", + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + found := false + for _, mu := range hub.memberUpdates { + if mu.roleName == "Mods" { + found = true + } + } + if !found { + t.Fatalf("expected a member_update carrying the renamed role, got %+v", hub.memberUpdates) + } +} + +// A PATCH that changes nothing about the name must not emit a spurious +// member_update — only permission changes (handled separately) or a rename +// should. +func TestAdminAPI_PatchRole_NoRenameNoMemberUpdate(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, _, token := newRolesHandler(t, database) + + createUserWithRole(t, database, "norenametest", 3) + + w := doRequest(t, handler, http.MethodPatch, "/roles/3", token, map[string]any{ + "color": "#abcdef", + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + if len(hub.memberUpdates) != 0 { + t.Fatalf("expected no member_update for a color-only patch, got %+v", hub.memberUpdates) + } +} diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index a0ccaf4d..4584f974 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -3,6 +3,7 @@ package admin import ( "encoding/json" "errors" + "math" "net/http" "time" @@ -29,8 +30,8 @@ func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc { func handleListUsers(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - limit := queryInt(r, "limit", 50, 1) - offset := queryInt(r, "offset", 0, 0) + limit := queryInt(r, "limit", 50, 1, 500) + offset := queryInt(r, "offset", 0, 0, math.MaxInt32) users, err := database.ListAllUsers(r.Context(), limit, offset) if err != nil { @@ -61,6 +62,20 @@ type patchUserRequest struct { // effectively permanent and should be issued as such. const maxBanDurationHours = 24 * 365 +// memberUnbanBroadcaster is an optional capability of HubBroadcaster: tell +// every connected client a user is back in the roster after an unban, the +// mirror of BroadcastMemberBan. It is checked with a type assertion instead +// of being added to HubBroadcaster directly (admin/types.go, not owned by +// this change) so this fix does not force every HubBroadcaster +// implementation — production and test doubles alike — to gain the method +// before it compiles. See the batch report's cross_batch note: *ws.Hub needs +// BroadcastMemberUnban(userID int64) wired up for this to take effect at +// runtime; until then the assertion below simply misses and the handler's +// existing (pre-fix) behavior is unchanged. +type memberUnbanBroadcaster interface { + BroadcastMemberUnban(userID int64) +} + // writeModerationErr maps ModerationService errors onto admin API responses. func writeModerationErr(w http.ResponseWriter, err error) { switch { @@ -143,8 +158,17 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis writeModerationErr(w, actionErr) return } - if *req.Banned && hub != nil { + switch { + case *req.Banned && hub != nil: hub.BroadcastMemberBan(id) + case !*req.Banned && hub != nil: + // Ban had no WS event on the way out (member_ban hard-deletes + // the row client-side); unban needs one on the way back in, or + // every already-connected client keeps the user missing from + // its member store while a freshly connecting client sees them. + if mub, ok := hub.(memberUnbanBroadcaster); ok { + mub.BroadcastMemberUnban(id) + } } } @@ -168,6 +192,14 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil { if hub != nil { hub.BroadcastMemberUpdate(id, role.Name) + // BroadcastMemberUpdate only revokes subscriptions the new + // role can no longer read (hub_broadcast.go's + // revokeUnreadableChannels); it never grants the ones the + // new role newly gained READ_MESSAGES on. Without this, + // a promoted user's sidebar is missing channels until + // their next reconnect, unlike a role permission edit or + // a role delete, which both re-derive visibility fully. + hub.RefreshAllChannelVisibility() } } } diff --git a/Server/admin/handlers_users_broadcast_test.go b/Server/admin/handlers_users_broadcast_test.go new file mode 100644 index 00000000..c60c18e7 --- /dev/null +++ b/Server/admin/handlers_users_broadcast_test.go @@ -0,0 +1,89 @@ +package admin_test + +import ( + "context" + "net/http" + "testing" + + "github.com/owncord/server/admin" +) + +// unbanMockHub wraps mockHub (admin/api_test.go) and additionally implements +// the optional memberUnbanBroadcaster capability handlePatchUser looks for +// via a type assertion, so these tests can observe whether an unban fired +// the mirror of BroadcastMemberBan. +type unbanMockHub struct { + *mockHub + unbannedIDs []int64 +} + +func (m *unbanMockHub) BroadcastMemberUnban(userID int64) { + m.unbannedIDs = append(m.unbannedIDs, userID) +} + +// An unban must tell every already-connected client the user is back in the +// roster — the mirror of the ban path's BroadcastMemberBan — or they stay +// missing from every connected client's member store until that client +// reconnects (v022). +func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) { + database := openAdminTestDB(t) + hub := &unbanMockHub{mockHub: &mockHub{}} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + targetUID, _ := database.CreateUser(context.Background(), "unbanbroadcast", "hash", 3) + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ + "banned": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("ban: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ + "banned": false, + }) + if w.Code != http.StatusOK { + t.Fatalf("unban: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + if len(hub.unbannedIDs) != 1 || hub.unbannedIDs[0] != targetUID { + t.Fatalf("BroadcastMemberUnban calls = %v, want exactly [%d]", hub.unbannedIDs, targetUID) + } + if len(hub.memberBanIDs) != 1 || hub.memberBanIDs[0] != targetUID { + t.Fatalf("BroadcastMemberBan calls = %v, want exactly [%d] (unaffected by the unban change)", hub.memberBanIDs, targetUID) + } +} + +// A role change must re-derive channel visibility for the promoted user, not +// just revoke what they can no longer read — otherwise a channel the new +// role newly gained READ_MESSAGES on never appears in their sidebar until +// they reconnect (v025). +func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + targetUID, _ := database.CreateUser(context.Background(), "rolerefresh", "hash", 3) + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ + "role_id": 2, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + if hub.allVisibilityRefreshes != 1 { + t.Fatalf("RefreshAllChannelVisibility calls = %d, want 1", hub.allVisibilityRefreshes) + } + found := false + for _, mu := range hub.memberUpdates { + if mu.userID == targetUID { + found = true + } + } + if !found { + t.Fatalf("expected a member_update for the role change, got %+v", hub.memberUpdates) + } +} diff --git a/Server/admin/harvest_s5_roles_test.go b/Server/admin/harvest_s5_roles_test.go new file mode 100644 index 00000000..d3fb80c1 --- /dev/null +++ b/Server/admin/harvest_s5_roles_test.go @@ -0,0 +1,51 @@ +package admin_test + +// 2026-08-06 harvest S5: PATCH /roles/{id} must blanket-invalidate the +// permission cache when the affected-member lookup fails — role.go documents +// that fallback, but the handler only ever did per-user evictions. + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/permissions" + "github.com/owncord/server/service" +) + +// failingMembersStore makes exactly the role-member lookup fail, the way a +// transient reader-pool error would, while every other query keeps working. +type failingMembersStore struct { + service.Store +} + +func (failingMembersStore) ListUserIDsByRole(context.Context, int64) ([]int64, error) { + return nil, errors.New("reader pool exhausted") +} + +func TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + roleSvc := service.NewRoleService( + failingMembersStore{Store: database}, + service.NewPermissionService(database, permissions.NewChecker(database)), + ) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, + newTestModService(database), roleSvc) + token := createAdminUser(t, database) + + // Strip the seeded Member role down to READ_MESSAGES — a permissions + // change whose members could not be enumerated. + w := doRequest(t, handler, http.MethodPatch, "/roles/3", token, map[string]any{ + "permissions": permissions.ReadMessages, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if inv.invalidateAllN == 0 { + t.Fatalf("permissions changed but the member lookup failed and nothing was blanket-invalidated (InvalidateAll=0, per-user=%v) — the members keep their revoked grants until the cache TTL", inv.invalidateUserIDs) + } +} diff --git a/Server/admin/helpers.go b/Server/admin/helpers.go index c1f2b45b..f439d7a4 100644 --- a/Server/admin/helpers.go +++ b/Server/admin/helpers.go @@ -35,8 +35,11 @@ func pathInt64(r *http.Request, param string) (int64, error) { //nolint:unparam } // queryInt parses an integer query parameter with a minimum and maximum bound. -// Use minVal=1 for limit parameters, minVal=0 for offset parameters. -func queryInt(r *http.Request, key string, defaultVal, minVal int) int { +// Use minVal=1 for limit parameters, minVal=0 for offset parameters. maxVal +// caps limit parameters to prevent unbounded result sets; offset callers pass +// a large bound — clamping offset with the limit cap would make pagination +// unable to advance past row maxVal+limit. +func queryInt(r *http.Request, key string, defaultVal, minVal, maxVal int) int { raw := r.URL.Query().Get(key) if raw == "" { return defaultVal @@ -45,10 +48,8 @@ func queryInt(r *http.Request, key string, defaultVal, minVal int) int { if err != nil || n < minVal { return defaultVal } - // Cap to prevent unbounded result sets exhausting memory. - const maxLimit = 500 - if n > maxLimit { - return maxLimit + if n > maxVal { + return maxVal } return n } diff --git a/Server/admin/helpers_internal_test.go b/Server/admin/helpers_internal_test.go new file mode 100644 index 00000000..0f359836 --- /dev/null +++ b/Server/admin/helpers_internal_test.go @@ -0,0 +1,24 @@ +package admin + +import ( + "math" + "net/http/httptest" + "testing" +) + +// queryInt's result-set cap belongs to limit parameters only. Clamping offset +// with the same cap means the audit log and user list can never page past row +// maxLimit+limit — the admin panel loops on the same page forever. +func TestQueryInt_OffsetNotClampedByLimitCap(t *testing.T) { + r := httptest.NewRequest("GET", "/?offset=550", nil) + if got := queryInt(r, "offset", 0, 0, math.MaxInt32); got != 550 { + t.Errorf("offset=550 parsed as %d, want 550", got) + } +} + +func TestQueryInt_LimitStillCapped(t *testing.T) { + r := httptest.NewRequest("GET", "/?limit=9999", nil) + if got := queryInt(r, "limit", 50, 1, 500); got != 500 { + t.Errorf("limit=9999 parsed as %d, want the 500 cap", got) + } +} diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index 2bef7dd1..a4ad936f 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -156,6 +156,13 @@ func (rb *RingBuffer) Write(entry LogEntry) { func (rb *RingBuffer) Snapshot() []LogEntry { rb.mu.Lock() defer rb.mu.Unlock() + return rb.snapshotLocked() +} + +// snapshotLocked is Snapshot's body, callable by callers that already hold +// rb.mu (SnapshotAndSubscribe needs the copy and the subscription to happen +// under the same critical section). +func (rb *RingBuffer) snapshotLocked() []LogEntry { out := make([]LogEntry, rb.count) if rb.count < len(rb.entries) { // Not yet wrapped: entries [0, count) are already in order. @@ -171,11 +178,18 @@ func (rb *RingBuffer) Snapshot() []LogEntry { // Subscribe creates a buffered channel for a new SSE client. // Returns the channel and an unsubscribe function. func (rb *RingBuffer) Subscribe() (<-chan LogEntry, func()) { + rb.mu.Lock() + ch, unsub := rb.subscribeLocked() + rb.mu.Unlock() + return ch, unsub +} + +// subscribeLocked is Subscribe's body, callable by callers that already hold +// rb.mu. +func (rb *RingBuffer) subscribeLocked() (<-chan LogEntry, func()) { ch := make(chan LogEntry, 64) chp := &ch - rb.mu.Lock() rb.subscribers[chp] = struct{}{} - rb.mu.Unlock() return ch, func() { rb.mu.Lock() @@ -184,6 +198,25 @@ func (rb *RingBuffer) Subscribe() (<-chan LogEntry, func()) { } } +// SnapshotAndSubscribe atomically copies the current backfill entries and +// registers a new subscriber channel under a single lock acquisition. +// +// Doing this as two separate calls (Snapshot() then Subscribe()) leaves a +// window between them where Write's fan-out — which only reaches entries +// already in rb.subscribers — cannot deliver to a caller that has not +// subscribed yet, while the caller's snapshot was already taken and will +// never include it either. Any entry written in that window is lost from +// both the backfill and the live feed. handleLogStream's window is not +// instantaneous: a token-resolution DB round-trip runs per backfilled entry +// before the (formerly) separate Subscribe() call. +func (rb *RingBuffer) SnapshotAndSubscribe() ([]LogEntry, <-chan LogEntry, func()) { + rb.mu.Lock() + defer rb.mu.Unlock() + out := rb.snapshotLocked() + ch, unsub := rb.subscribeLocked() + return out, ch, unsub +} + // multiHandler is an slog.Handler that tees records to two handlers: // the original stdout handler and a ring buffer handler. type multiHandler struct { @@ -404,8 +437,15 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { w.WriteHeader(http.StatusOK) flusher.Flush() + // Snapshot the backfill and subscribe to new entries atomically: the + // per-entry principalStillAuthorized() check below is a DB round-trip, + // so the backfill loop is slow enough that a Snapshot()-then-Subscribe() + // gap would silently drop any entry written in between (v059). + backfill, ch, unsub := ringBuf.SnapshotAndSubscribe() + defer unsub() + // Send backfill. - for _, entry := range ringBuf.Snapshot() { + for _, entry := range backfill { if !principalStillAuthorized() { return } @@ -415,10 +455,6 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { } flusher.Flush() - // Subscribe for new entries. - ch, unsub := ringBuf.Subscribe() - defer unsub() - // Keepalive ticker to avoid WriteTimeout (30s). keepalive := time.NewTicker(15 * time.Second) defer keepalive.Stop() diff --git a/Server/admin/logstream_atomic_test.go b/Server/admin/logstream_atomic_test.go new file mode 100644 index 00000000..04f622d2 --- /dev/null +++ b/Server/admin/logstream_atomic_test.go @@ -0,0 +1,155 @@ +package admin + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/owncord/server/auth" +) + +// handleLogStream's backfill loop does a DB round-trip per entry between +// taking the snapshot and registering the live subscription — Snapshot() +// followed by a separate Subscribe() call left a window where a write in +// between landed in neither (v059). SnapshotAndSubscribe closes that window +// by doing both under one lock acquisition. +func TestRingBuffer_SnapshotAndSubscribe_NoGap(t *testing.T) { + buf := NewRingBuffer(10) + buf.Write(LogEntry{Message: "before"}) + + snap, ch, unsub := buf.SnapshotAndSubscribe() + defer unsub() + + if len(snap) != 1 || snap[0].Message != "before" { + t.Fatalf("snapshot = %+v, want [{Message: before}]", snap) + } + + // A write that lands after the atomic call returns must reach the + // channel — it is neither in the snapshot nor lost. + buf.Write(LogEntry{Message: "after"}) + + select { + case e := <-ch: + if e.Message != "after" { + t.Fatalf("got %+v, want Message=after", e) + } + default: + t.Fatal("expected the post-subscribe write to be delivered on the channel, got nothing") + } +} + +// The snapshot and the subscription returned by SnapshotAndSubscribe must +// still behave like independently-called Snapshot/Subscribe: entries already +// in the ring appear in the snapshot, not replayed on the channel. +func TestRingBuffer_SnapshotAndSubscribe_SnapshotExcludedFromChannel(t *testing.T) { + buf := NewRingBuffer(10) + buf.Write(LogEntry{Message: "already-in-ring"}) + + snap, ch, unsub := buf.SnapshotAndSubscribe() + defer unsub() + + if len(snap) != 1 { + t.Fatalf("snapshot = %+v, want 1 entry", snap) + } + select { + case e := <-ch: + t.Fatalf("channel should not replay pre-existing entries, got %+v", e) + default: + } +} + +// gapProbeSSEWriter drives the handler from inside its own writes: the first +// backfilled entry triggers onFirstBackfill (which writes a fresh log line, +// i.e. exactly the interleaving the gap loses), and once wantData entries have +// been written the request context is cancelled so the handler returns. +type gapProbeSSEWriter struct { + header http.Header + statusCode int + dataWrites int + wantData int + buffer bytes.Buffer + onFirstBackfill func() + cancel func() +} + +func (w *gapProbeSSEWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *gapProbeSSEWriter) WriteHeader(statusCode int) { w.statusCode = statusCode } +func (w *gapProbeSSEWriter) Flush() {} + +func (w *gapProbeSSEWriter) Write(data []byte) (int, error) { + _, _ = w.buffer.Write(data) + if bytes.Contains(data, []byte("data: ")) { + w.dataWrites++ + if w.dataWrites == 1 && w.onFirstBackfill != nil { + w.onFirstBackfill() + } + if w.dataWrites >= w.wantData && w.cancel != nil { + w.cancel() + } + } + return len(data), nil +} + +// The end-to-end shape of v059: a log line written *while the backfill loop is +// running* must still reach the stream. Under the old Snapshot()-then- +// Subscribe() ordering it was in neither — the snapshot predated it and the +// subscription did not exist yet — and this test hits that window +// deterministically by doing the write from inside the first backfill entry's +// Write call. +func TestHandleLogStream_EntryWrittenDuringBackfillIsDelivered(t *testing.T) { + database := newLogStreamTestDB(t) + logBuf := NewRingBuffer(8) + logBuf.Write(LogEntry{Timestamp: "2026-08-07T10:00:00Z", Level: "info", Message: "backfill-one", Source: "test"}) + logBuf.Write(LogEntry{Timestamp: "2026-08-07T10:00:01Z", Level: "info", Message: "backfill-two", Source: "test"}) + + userID, err := database.CreateUser(context.Background(), "owner", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + ticket, err := logTickets.issue(tokenHash) + if err != nil { + t.Fatalf("issue ticket: %v", err) + } + + // The timeout is the failure path only: with the gap open the third entry + // never arrives, so nothing would ever cancel the stream. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req := httptest.NewRequest(http.MethodGet, "/logs/stream?ticket="+ticket, nil).WithContext(ctx) + writer := &gapProbeSSEWriter{ + header: make(http.Header), + wantData: 3, + cancel: cancel, + onFirstBackfill: func() { + logBuf.Write(LogEntry{Timestamp: "2026-08-07T10:00:02Z", Level: "warn", Message: "written-during-backfill", Source: "test"}) + }, + } + + handleLogStream(database, logBuf).ServeHTTP(writer, req) + + if writer.statusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", writer.statusCode, writer.buffer.String()) + } + if !strings.Contains(writer.buffer.String(), "written-during-backfill") { + t.Fatalf("entry written during the backfill was lost from both the backfill and the live feed; body = %s", writer.buffer.String()) + } +} diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index dd1a5ad5..6249941b 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -418,6 +418,7 @@ func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {} func (m *mockHubWB) RefreshAllChannelVisibility() {} func (m *mockHubWB) BroadcastRolesUpdate(roles []*db.Role) {} +func (m *mockHubWB) CleanupVoiceForChannel(channelID int64) {} func (m *mockHubWB) ClientCount() int { return 0 } // isolateSpawnedTestBinary makes it safe for a test to re-exec the test binary diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index fbead1eb..5afa8b01 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -1950,7 +1950,13 @@ async function renderUpdates(){ else if(info&&info.update_available)html+='<div class="update-card" style="border-color:var(--accent)"><div class="update-icon" style="background:var(--accent-glow);color:var(--accent)">'+I.updates+'</div><div class="update-info"><div class="update-ver">'+esc(info.latest)+' <span class="badge badge-accent">New</span></div><div class="update-notes">Available for download</div></div></div>'; else html+='<div class="update-card"><div class="update-icon" style="background:rgba(35,165,90,.15);color:var(--green)">'+I.check+'</div><div class="update-info"><div class="update-ver">Up to date</div><div class="update-notes">You\'re running the latest version</div></div></div>'; html+='</div>'; - if(info&&info.update_available){ + if(info&&info.update_available&&info.can_apply===false){ + /* Container deployments: the binary is image content, so in-place apply is + refused server-side (503 CONTAINER_DEPLOYMENT) — say so instead of + offering a button that can only fail. */ + html+='<div class="update-card"><div class="update-info"><div class="update-notes">In-place update is unavailable in container deployments — upgrade by pulling the new image and recreating the container.</div></div></div>'; + html+='<div style="margin-top:16px"><button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Check Again</button></div>'; + }else if(info&&info.update_available){ html+='<div style="display:flex;gap:8px"><button class="btn btn-danger" onclick="applyUpdate()" '+(state.updateApplying?'disabled':'')+'>'+(state.updateApplying?'<div class="spinner"></div> Applying...':'Apply Update & Restart')+'</button>'; html+='<button class="btn btn-ghost" onclick="renderContent()">'+I.refresh+' Check Again</button></div>'; }else{ diff --git a/Server/admin/types.go b/Server/admin/types.go index befd0329..37dfcfed 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -66,6 +66,11 @@ type HubBroadcaster interface { // after a role mutation, so name colors and permission-gated affordances // converge without a reconnect. BroadcastRolesUpdate(roles []*db.Role) + // CleanupVoiceForChannel evicts a channel's voice participants (DB row, + // client state, LiveKit, voice_leave broadcast). Must run BEFORE the + // channel row is deleted: the voice_states FK cascade wipes the rows the + // cleanup reads. + CleanupVoiceForChannel(channelID int64) ClientCount() int } diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 2fc11a30..9f72c348 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -13,7 +13,9 @@ import ( "golang.org/x/mod/semver" ) -// handleCheckUpdate returns the current update status. +// handleCheckUpdate returns the current update status. can_apply tells the +// admin SPA whether POST /updates/apply is usable in this deployment (false +// in containers, where upgrades are image pulls). func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if u == nil { @@ -26,14 +28,25 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", "failed to check for updates — see server logs") return } - writeJSON(w, http.StatusOK, info) + writeJSON(w, http.StatusOK, struct { + updater.UpdateInfo + CanApply bool `json:"can_apply"` + }{info, !updater.RunningInContainer()}) } } // handleApplyUpdate downloads and applies a server update. func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler { - // TODO: maybe disable this endpoint in future docker build type? return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // In a container the running binary is image content: the staged + // replacement dies with the container and the restart comes back as + // the old image. Refuse before any nil/availability logic so the + // answer does not depend on updater configuration. + if updater.RunningInContainer() { + writeErr(w, http.StatusServiceUnavailable, "CONTAINER_DEPLOYMENT", + "in-place self-update is disabled in container deployments — upgrade by pulling the new image") + return + } if u == nil { writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") return diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index c3ef2006..67ca4652 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -357,3 +357,50 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { t.Errorf("status = %d; expected download attempt to proceed (got 503/409 instead)", w.Code) } } + +// ─── handleApplyUpdate — container deployments ─────────────────────────────── + +// In a container the staged replacement dies with the container, so the +// endpoint refuses before any updater logic runs — the answer must not +// depend on whether an updater is configured (closes the long-standing +// "disable this endpoint in docker builds?" TODO). +func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) { + t.Setenv("OWNCORD_CONTAINER", "1") + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["error"] != "CONTAINER_DEPLOYMENT" { + t.Errorf("error code = %q, want CONTAINER_DEPLOYMENT", resp["error"]) + } +} + +// The explicit opt-out keeps in-place update available for operators who +// bind-mount the binary and know what they are doing: with the variable set +// to 0, the container guard steps aside and the nil-updater 503 answers. +func TestAdminAPI_ApplyUpdate_ContainerOptOut(t *testing.T) { + t.Setenv("OWNCORD_CONTAINER", "0") + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["error"] != "UPDATE_UNAVAILABLE" { + t.Errorf("error code = %q, want UPDATE_UNAVAILABLE (container guard must step aside)", resp["error"]) + } +} diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 17580273..f922bf47 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -67,12 +67,32 @@ type authSuccessResponse struct { User *userResponse `json:"user,omitempty"` } +// AuthBroadcaster is the interface handleDeleteAccount uses to notify +// connected WebSocket clients that an account is gone. Satisfied by *ws.Hub +// (which already implements BroadcastMemberBan for the admin ban path this +// mirrors). +type AuthBroadcaster interface { + BroadcastMemberBan(userID int64) +} + // MountAuthRoutes registers all auth endpoints on the given router. // Rate limiters are applied per-endpoint as specified. trustedProxies is the // list of CIDRs whose X-Forwarded-For / X-Real-IP headers are honoured for // rate-limiting IP resolution. totpKey is the AES-256 key used to encrypt // TOTP secrets at rest (M1 security hardening). -func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte) { +// +// broadcaster is variadic and optional: MountAuthRoutes is called before the +// hub exists (router.go mounts auth routes first, and the hub needs the +// router to register its own webhook route), so a caller that cannot supply +// one yet may omit it entirely and self-deletion simply sends no event, +// exactly like today. A caller mounted after hub creation should pass it so +// DELETE /api/v1/auth/account can broadcast the same member_ban event the +// admin ban path already sends for the identical anonymise-and-ban DB state. +func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte, broadcaster ...AuthBroadcaster) { + var ab AuthBroadcaster + if len(broadcaster) > 0 { + ab = broadcaster[0] + } registerLimiter := limiter loginLimiter := limiter partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL) @@ -80,13 +100,13 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t usedTOTPCodes := auth.NewUsedTOTPCodeStore() r.Route("/api/v1/auth", func(r chi.Router) { - r.With(RateLimitMiddleware(registerLimiter, registerRateLimitPerMinute, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(registerLimiter, "register:", registerRateLimitPerMinute, time.Minute, trustedProxies)). Post("/register", handleRegister(database)) - r.With(RateLimitMiddleware(loginLimiter, loginRateLimitPerMinute, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(loginLimiter, "login:", loginRateLimitPerMinute, time.Minute, trustedProxies)). Post("/login", handleLogin(database, limiter, partialStore, trustedProxies)) - r.With(RateLimitMiddleware(limiter, verifyTOTPRateLimitPerMinute, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(limiter, "totp_verify:", verifyTOTPRateLimitPerMinute, time.Minute, trustedProxies)). Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey)) r.With(AuthMiddleware(database)). @@ -96,20 +116,20 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t Get("/me", handleMe()) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). - Delete("/account", handleDeleteAccount(database, limiter)) + RateLimitMiddleware(limiter, "del_account:", sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). + Delete("/account", handleDeleteAccount(database, limiter, ab)) }) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, "totp:", sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Post("/api/v1/users/me/totp/enable", handleEnableTOTP(pendingTOTPStore, limiter)) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, "totp:", sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter, totpKey)) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, "totp:", sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Delete("/api/v1/users/me/totp", handleDisableTOTP(database, pendingTOTPStore, limiter)) } @@ -512,7 +532,10 @@ type deleteAccountRequest struct { // handleDeleteAccount processes DELETE /api/v1/auth/account. // The caller must supply their current password for confirmation. // Progressive lockout mirrors the login handler: 3 failures → 15-min lock. -func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc { +// broadcaster may be nil, in which case no event is sent and other connected +// clients converge on their next reconnect instead (same fallback every +// other broadcaster-optional handler in this package uses). +func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter, broadcaster AuthBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserKey).(*db.User) if !ok || user == nil { @@ -585,6 +608,14 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID, "account self-deleted from "+ip) + // DeleteAccount left the row in exactly the state an admin ban does + // (anonymised, banned, sessions revoked) — broadcast the same event so + // every other connected client drops the deleted user immediately + // instead of keeping their pre-deletion username until it reconnects. + if broadcaster != nil { + broadcaster.BroadcastMemberBan(user.ID) + } + w.WriteHeader(http.StatusNoContent) } } diff --git a/Server/api/auth_handler_delete_broadcast_test.go b/Server/api/auth_handler_delete_broadcast_test.go new file mode 100644 index 00000000..2592d58f --- /dev/null +++ b/Server/api/auth_handler_delete_broadcast_test.go @@ -0,0 +1,74 @@ +package api_test + +import ( + "context" + "net/http" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" +) + +// recordingAuthBroadcaster records BroadcastMemberBan calls so tests can +// assert self-service account deletion fans out the same event the admin +// ban path does. +type recordingAuthBroadcaster struct { + bannedIDs []int64 +} + +func (r *recordingAuthBroadcaster) BroadcastMemberBan(userID int64) { + r.bannedIDs = append(r.bannedIDs, userID) +} + +// Self-service account deletion left DELETE /api/v1/auth/account with no way +// to notify other connected clients: DeleteAccount anonymises and bans the +// row exactly like the admin ban path does, but only the admin path +// broadcast an event. Every other connected client kept the deleted user's +// pre-deletion username in its member list until it reconnected (v068). +func TestDeleteAccount_BroadcastsMemberBan(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + broadcaster := &recordingAuthBroadcaster{} + + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey, broadcaster) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser(context.Background(), "deletebroadcast", hash, 4) + token, _ := auth.GenerateToken() + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") + + rr := deleteJSONWithToken(t, r, "/api/v1/auth/account", token, map[string]string{ + "password": "correctPass1", + }) + if rr.Code != http.StatusNoContent { + t.Fatalf("DeleteAccount status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + + if len(broadcaster.bannedIDs) != 1 || broadcaster.bannedIDs[0] != uid { + t.Fatalf("BroadcastMemberBan calls = %v, want exactly [%d]", broadcaster.bannedIDs, uid) + } +} + +// Omitting the broadcaster (the shape every existing MountAuthRoutes call +// site uses today) must keep working exactly as before: no event, no panic. +func TestDeleteAccount_NoBroadcasterOmitted(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser(context.Background(), "deletenobroadcast", hash, 4) + token, _ := auth.GenerateToken() + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") + + rr := deleteJSONWithToken(t, r, "/api/v1/auth/account", token, map[string]string{ + "password": "correctPass1", + }) + if rr.Code != http.StatusNoContent { + t.Fatalf("DeleteAccount status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } +} diff --git a/Server/api/bodycap_test.go b/Server/api/bodycap_test.go new file mode 100644 index 00000000..b62b1797 --- /dev/null +++ b/Server/api/bodycap_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// The global body cap must not shadow routes that enforce their own larger +// envelope: MaxBytesReader wrappers only delegate reads, so the innermost +// (global) 1 MiB limit errors first and the route's documented cap becomes +// unreachable — the 16 MiB plugin upload 400s at ~1 MiB, and an at-limit +// avatar can never fit its multipart framing. +func TestBodyCapExemptions_RouteEnvelopesReachable(t *testing.T) { + drain := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + h := MaxBodySizeUnless(defaultMaxBodySize, bodyCapExemptPrefixes...)(drain) + + body := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB — over the global cap + cases := []struct { + path string + want int + }{ + {"/api/v1/uploads", http.StatusOK}, + {"/api/v1/admin/plugins/install", http.StatusOK}, + {"/api/v1/users/me/avatar", http.StatusOK}, + // Everything else keeps the global cap. + {"/api/v1/channels/1/messages", http.StatusRequestEntityTooLarge}, + } + for _, tc := range cases { + req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(body)) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != tc.want { + t.Errorf("%s with 2 MiB body: status = %d, want %d", tc.path, rr.Code, tc.want) + } + } +} diff --git a/Server/api/constants.go b/Server/api/constants.go index f0a96111..36ebd638 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -126,13 +126,30 @@ const ( rateLimiterCleanupInterval = 5 * time.Minute // rateLimiterCleanupMaxWindow is the maximum window considered when pruning - // stale rate-limiter entries. - rateLimiterCleanupMaxWindow = 15 * time.Minute + // stale rate-limiter entries. It must cover the LARGEST window any caller + // passes to Allow: slow mode (service/message_crud.go) uses windows up to + // admin's maxSlowModeSeconds (21600 s = 6 h), and a shorter horizon makes + // the reaper silently reset long slow modes after ~15 minutes. + rateLimiterCleanupMaxWindow = 6 * time.Hour // hstsMaxAgeSeconds is the max-age value for the Strict-Transport-Security header. hstsMaxAgeSeconds = 31536000 ) +// bodyCapExemptPrefixes are the route prefixes excluded from the global 1 MiB +// body cap because they enforce their own, larger envelope at the route or +// handler level. A route with a documented cap above 1 MiB that is missing +// here is unreachable at its own limit: MaxBytesReader wrappers merely +// delegate reads, so the innermost (global) limit errors first. +var bodyCapExemptPrefixes = []string{ + "/api/v1/uploads", + // 16 MiB plugin envelope enforced by the handler's own MaxBytesReader. + "/api/v1/admin/plugins/install", + // 2 MiB avatar envelope: route-scoped MaxBodySize(avatarMaxBodySize) + // plus the handler's re-wrap enforce it. + "/api/v1/users/me/avatar", +} + // ─── Size limits ──────────────────────────────────────────────────────────── const ( diff --git a/Server/api/constants_test.go b/Server/api/constants_test.go index c5863493..3bb15ca9 100644 --- a/Server/api/constants_test.go +++ b/Server/api/constants_test.go @@ -8,3 +8,16 @@ func TestLoginRateLimit_Value(t *testing.T) { t.Errorf("loginRateLimitPerMinute = %d, want 5", loginRateLimitPerMinute) } } + +// The rate-limiter reaper deletes any window entry whose timestamps are all +// older than rateLimiterCleanupMaxWindow, which is only safe for windows no +// longer than that horizon (auth/ratelimit.go). Slow mode uses the limiter +// with windows up to admin's maxSlowModeSeconds (21600 s = 6 h), so a shorter +// horizon silently resets long slow modes after ~15 minutes. +func TestRateLimiterCleanupHorizon_CoversMaxSlowMode(t *testing.T) { + const maxSlowMode = 21600 // admin/handlers_channels.go maxSlowModeSeconds + if rateLimiterCleanupMaxWindow.Seconds() < maxSlowMode { + t.Errorf("rateLimiterCleanupMaxWindow = %v, must cover the %ds slow-mode cap", + rateLimiterCleanupMaxWindow, maxSlowMode) + } +} diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index b10643dc..0fc604eb 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/db" "github.com/owncord/server/service" + "github.com/owncord/server/ws" ) // DMBroadcaster is the interface needed to send WebSocket events from REST @@ -18,6 +19,49 @@ type DMBroadcaster interface { SendToUser(userID int64, msg []byte) bool } +// dmVisibilityMarker is an optional DMBroadcaster capability: bump the hub's +// visibility watermark after an unsequenced, targeted event so a client that +// warm-reconnects across the gap takes the full-ready path instead of a +// sequenced-only replay that can never redeliver it. Reached by type +// assertion rather than being added to DMBroadcaster directly so the +// SendToUser-only test doubles in this package keep working. +// +// NOTE: *ws.Hub does not export this yet — its watermark bump +// (ws/hub.go bumpVisibilityWatermark, the same one ws/emit.go forces for the +// WS-side dm_channel_open) is unexported, so until a one-line exported +// wrapper lands the assertion below misses and this is a no-op. +type dmVisibilityMarker interface { + MarkVisibilityChanged() +} + +// markDMVisibilityChanged bumps the visibility watermark if broadcaster +// supports it. dm_channel_open/close are unsequenced and targeted, so a +// client that misses one via a dropped connection and then warm-reconnects +// never gets it redelivered by the ordinary seq-replay path — mirroring why +// the WS emitter of the same event (ws/emit.go DMChannelOpenEvent) forces +// this bump unconditionally, regardless of whether the send itself +// succeeded. +func markDMVisibilityChanged(broadcaster DMBroadcaster) { + if vm, ok := broadcaster.(dmVisibilityMarker); ok { + vm.MarkVisibilityChanged() + } +} + +// dmVoiceEvictor is the DMBroadcaster capability used to evict a user's +// voice-call connection for one specific channel, leaving an unrelated call +// they may currently be in untouched (which the unconditional +// DisconnectFromVoice would not). It is kept out of DMBroadcaster itself and +// reached by type assertion so the handler stays usable with the +// SendToUser-only test doubles the package already has. +type dmVoiceEvictor interface { + DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool +} + +// The production broadcaster must keep satisfying it: a type assertion that +// silently stops matching would turn the eviction back into the no-op this +// fixed, with nothing failing to say so. +var _ dmVoiceEvictor = (*ws.Hub)(nil) + // MountDMRoutes registers DM-related routes onto r. // All routes require authentication. // hub is used to send real-time WebSocket events on DM close. @@ -165,16 +209,33 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle // Notify via WebSocket so sidebar updates immediately. if broadcaster != nil { - closeMsg := fmt.Appendf(nil, `{"type":"dm_channel_close","payload":{"channel_id":%d}}`, channelID) + closeMsg := fmt.Appendf(nil, `{"type":%q,"payload":{"channel_id":%d}}`, ws.MsgTypeDMChannelClose, channelID) if ok := broadcaster.SendToUser(user.ID, closeMsg); !ok { slog.Debug("handleCloseDM: user not connected", "user_id", user.ID, "channel_id", channelID) } + // dm_channel_close is unsequenced and targeted like + // dm_channel_open — see markDMVisibilityChanged. + markDMVisibilityChanged(broadcaster) // A group leave changes the membership everyone else renders, so // the survivors get a refreshed dm_channel_open rather than being // left showing a member who has gone. if result.Left && !result.ChannelDeleted { broadcastDMOpen(r.Context(), svc, broadcaster, channelID, result.RemainingParticipantIDs) } + // Leaving a group DM removes the caller from its membership but, + // without this, leaves them connected to its live voice call — + // they keep hearing and speaking to a room they are no longer a + // member of. Scoped to this channel so a leaver currently on an + // unrelated voice call is untouched. This also covers the + // last-participant case (ChannelDeleted): the row is already gone + // by now, so CleanupVoiceForChannel would read an FK-cascaded + // empty voice_states and do nothing, while the leaver — the only + // participant left — is evicted here. + if result.Left { + if ve, ok := broadcaster.(dmVoiceEvictor); ok { + ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), user.ID, channelID) + } + } } w.WriteHeader(http.StatusNoContent) @@ -192,6 +253,14 @@ func broadcastDMOpen(ctx context.Context, svc *service.Services, broadcaster DMB if broadcaster == nil || len(targetIDs) == 0 { return } + // dm_channel_open is unsequenced and targeted — a recipient who is + // offline or drops the connection right now can never have it replayed + // to them by the ordinary seq-based resume path, so a warm reconnect must + // be forced onto the full-ready path instead. Bumped once per call, + // unconditionally (not per-recipient SendToUser result): the ws emitter + // of this same event does the same (ws/emit.go), and this covers every + // caller — group create, rename refresh, and the group-leave refresh. + markDMVisibilityChanged(broadcaster) for _, pid := range targetIDs { summary, pErr := svc.DMs.DMSummaryFor(ctx, pid, channelID) if pErr != nil { diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index de42795f..0532dcd0 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -103,6 +103,22 @@ CREATE TABLE IF NOT EXISTS message_mentions ( PRIMARY KEY (message_id, mentioned_user_id) ); +-- Mirrors migrations/001. Required, not optional decoration: the DM close +-- path (db.LeaveGroupDM) unlinks a channel's attachments before hard-deleting +-- the channel row, because messages.channel_id and attachments.message_id both +-- cascade ON DELETE and the cascade would otherwise destroy the rows the +-- orphan sweep needs to reclaim the files. Without this table the handler +-- answers 500. +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, diff --git a/Server/api/dm_handler_watermark_voice_test.go b/Server/api/dm_handler_watermark_voice_test.go new file mode 100644 index 00000000..c5b1a8b0 --- /dev/null +++ b/Server/api/dm_handler_watermark_voice_test.go @@ -0,0 +1,112 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" +) + +// watermarkVoiceBroadcaster wraps mockBroadcaster and additionally implements +// the optional MarkVisibilityChanged and DisconnectFromVoiceInChannel +// capabilities api's DM handlers check for via type assertions +// (dmVisibilityMarker, dmVoiceEvictor). +type watermarkVoiceBroadcaster struct { + *mockBroadcaster + markCalls int + evictCalls []evictCall +} + +type evictCall struct { + userID int64 + channelID int64 +} + +func (b *watermarkVoiceBroadcaster) MarkVisibilityChanged() { + b.markCalls++ +} + +func (b *watermarkVoiceBroadcaster) DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool { + b.evictCalls = append(b.evictCalls, evictCall{userID, channelID}) + return true +} + +// A group DM create is an unsequenced, targeted event — a recipient offline +// or dropping the connection right now can never have it replayed by the +// ordinary seq-based resume, so warm reconnects must be forced onto the +// full-ready path instead (v035). +func TestCreateGroupDM_BumpsVisibilityWatermark(t *testing.T) { + database := newDMTestDB(t) + bc := &watermarkVoiceBroadcaster{mockBroadcaster: &mockBroadcaster{}} + router := buildDMRouter(database, bc) + tokens := []string{ + dmCreateToken(t, database, "alice", 4), + dmCreateToken(t, database, "bob", 4), + dmCreateToken(t, database, "carol", 4), + } + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("create group dm: %d %s", rr.Code, rr.Body.String()) + } + + if bc.markCalls < 1 { + t.Fatalf("MarkVisibilityChanged calls = %d, want at least 1", bc.markCalls) + } +} + +// Leaving a group DM must evict the leaver's voice-call connection, scoped to +// that channel — otherwise they keep hearing and speaking to a room they are +// no longer a member of (v031). +func TestLeaveGroupDM_EvictsLeaverFromVoice(t *testing.T) { + database := newDMTestDB(t) + bc := &watermarkVoiceBroadcaster{mockBroadcaster: &mockBroadcaster{}} + router := buildDMRouter(database, bc) + tokens := []string{ + dmCreateToken(t, database, "alice", 4), + dmCreateToken(t, database, "bob", 4), + dmCreateToken(t, database, "carol", 4), + } + + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + bc.evictCalls = nil + + rr := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), tokens[1]) + if rr.Code != http.StatusNoContent { + t.Fatalf("leave: %d %s", rr.Code, rr.Body.String()) + } + + if len(bc.evictCalls) != 1 || bc.evictCalls[0].userID != 2 || bc.evictCalls[0].channelID != group.ChannelID { + t.Fatalf("DisconnectFromVoiceInChannel calls = %+v, want exactly one for user=2 channel=%d", bc.evictCalls, group.ChannelID) + } +} + +// Closing a 1:1 DM is a hide, not a leave — the closer's DM membership is +// unchanged, so their voice session (if any) must not be touched. +func TestCloseDM_OneToOneDoesNotEvictVoice(t *testing.T) { + database := newDMTestDB(t) + bc := &watermarkVoiceBroadcaster{mockBroadcaster: &mockBroadcaster{}} + router := buildDMRouter(database, bc) + tokens := []string{ + dmCreateToken(t, database, "alice", 4), + dmCreateToken(t, database, "bob", 4), + } + rr := dmPost(t, router, "/api/v1/dms", tokens[0], map[string]any{"recipient_id": 2}) + var created struct { + ChannelID int64 `json:"channel_id"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + + if delRR := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", created.ChannelID), tokens[0]); delRR.Code != http.StatusNoContent { + t.Fatalf("close: %d", delRR.Code) + } + + if len(bc.evictCalls) != 0 { + t.Fatalf("DisconnectFromVoiceInChannel calls = %+v, want none for a 1:1 close", bc.evictCalls) + } +} diff --git a/Server/api/livekit_ratelimit_test.go b/Server/api/livekit_ratelimit_test.go index 074ff3c4..c6c5bd4a 100644 --- a/Server/api/livekit_ratelimit_test.go +++ b/Server/api/livekit_ratelimit_test.go @@ -22,7 +22,7 @@ func TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket(t *testing.T) trustedProxies := []string{"127.0.0.0/8"} clientUpdate := rateLimitMiddlewareWithPrefix(limiter, "client_update:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) - sensitive := RateLimitMiddleware(limiter, 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + sensitive := RateLimitMiddleware(limiter, "totp_verify:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) newReq := func(path string) *http.Request { r := httptest.NewRequest(http.MethodGet, path, nil) @@ -56,7 +56,7 @@ func TestRateLimitMiddlewareWithPrefix_SeparatesLiveKitBucket(t *testing.T) { trustedProxies := []string{"127.0.0.0/8"} livekit := rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) - defaultRoute := RateLimitMiddleware(limiter, 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + defaultRoute := RateLimitMiddleware(limiter, "login:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) firstLiveKit := httptest.NewRequest(http.MethodGet, "/livekit/rtc", nil) firstLiveKit.RemoteAddr = "127.0.0.1:9999" diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 2fdc7523..6bce11e7 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -207,8 +207,14 @@ func RequirePermission(perm int64) func(http.Handler) http.Handler { // provided RateLimiter. The client IP is resolved via clientIPWithProxies using // the supplied trustedProxies CIDRs — pass nil to always use RemoteAddr. // Returns 429 with Retry-After when the limit is exceeded. -func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler { - return rateLimitMiddlewareWithPrefix(limiter, "", limit, window, trustedProxies...) +// +// prefix names the endpoint's bucket and must be non-empty in production +// mounts: the limiter records one timestamp per call regardless of the limit +// passed, so endpoints sharing a bare-IP key would cap each other at the +// MINIMUM limit of any of them (ordinary profile edits 429ing the password +// endpoint, NAT'd logins blocking register). +func RateLimitMiddleware(limiter *auth.RateLimiter, prefix string, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler { + return rateLimitMiddlewareWithPrefix(limiter, prefix, limit, window, trustedProxies...) } func rateLimitMiddlewareWithPrefix(limiter *auth.RateLimiter, prefix string, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler { diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index f74c9c89..715bcbb5 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -396,7 +396,7 @@ func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { func TestRateLimitMiddleware_UnderLimit(t *testing.T) { limiter := auth.NewRateLimiter() - h := api.RateLimitMiddleware(limiter, 5, time.Minute)(http.HandlerFunc(ok)) + h := api.RateLimitMiddleware(limiter, "test:", 5, time.Minute)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.1:1234" rr := httptest.NewRecorder() @@ -412,7 +412,7 @@ func TestRateLimitMiddleware_OverLimit(t *testing.T) { limiter := auth.NewRateLimiter() limit := 3 - h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok)) for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -435,7 +435,7 @@ func TestRateLimitMiddleware_OverLimit(t *testing.T) { func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) { limiter := auth.NewRateLimiter() - h := api.RateLimitMiddleware(limiter, 1, time.Minute)(http.HandlerFunc(ok)) + h := api.RateLimitMiddleware(limiter, "test:", 1, time.Minute)(http.HandlerFunc(ok)) // Exhaust limit. for range 2 { @@ -462,7 +462,7 @@ func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) { limiter := auth.NewRateLimiter() limit := 2 - h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok)) // Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP. for range limit { @@ -515,7 +515,7 @@ func TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest(t *test defer slog.SetDefault(prev) limiter := auth.NewRateLimiter() - h := api.RateLimitMiddleware(limiter, 100, time.Minute, + h := api.RateLimitMiddleware(limiter, "test:", 100, time.Minute, []string{"not-a-cidr", "10.0.0.0/8"})(http.HandlerFunc(ok)) const warnMsg = "ignoring invalid CIDR entry" @@ -548,7 +548,7 @@ func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { limit := 2 trustedCIDRs := []string{"10.0.0.0/8"} - h := api.RateLimitMiddleware(limiter, limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok)) + h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok)) // Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5. for range limit { diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 74f0749b..7d93417e 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -77,10 +77,10 @@ func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, st r.Route("/api/v1/users/me", func(r chi.Router) { r.Use(AuthMiddleware(database)) - r.With(RateLimitMiddleware(limiter, profileUpdateRateLimitPerMinute, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(limiter, "profile:", profileUpdateRateLimitPerMinute, time.Minute, trustedProxies)). Patch("/", handleUpdateProfile(svc, broadcaster)) - r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(limiter, "pw:", profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). Put("/password", handleChangePassword(svc, limiter)) if store != nil { @@ -241,11 +241,23 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) } if req.IdentityPublicKey != nil { - updated, err = svc.Users.UpdateIdentityKey(r.Context(), user.ID, *req.IdentityPublicKey) - if err != nil { - writeServiceError(r.Context(), w, err) + // Captured into a separate variable rather than reassigned into + // updated: on failure below, updated still holds the profile + // snapshot that DID commit, so it can still be broadcast instead + // of discarded. + withKey, keyErr := svc.Users.UpdateIdentityKey(r.Context(), user.ID, *req.IdentityPublicKey) + if keyErr != nil { + // The username/avatar/display_name/about write above already + // committed — only the identity key failed. Broadcasting the + // committed half keeps every other connected client in sync + // even though this request reports failure; leaving it + // unbroadcast would strand them on the old profile until + // their next ready. + broadcastUserUpdate(broadcaster, updated) + writeServiceError(r.Context(), w, keyErr) return } + updated = withKey } broadcastUserUpdate(broadcaster, updated) @@ -383,13 +395,9 @@ func handleListSessions(svc *service.Services) http.HandlerFunc { return } - sess, ok := r.Context().Value(SessionKey).(*db.Session) - if !ok || sess == nil { - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", Message: "not authenticated", - }) - return - } + // An API-token principal has a nil session (middleware.go); the list + // still works — no row is marked current. Only IsCurrent needs it. + sess, _ := r.Context().Value(SessionKey).(*db.Session) sessions, err := svc.Users.ListSessions(r.Context(), user.ID) if err != nil { @@ -407,7 +415,7 @@ func handleListSessions(svc *service.Services) http.HandlerFunc { IP: s.IP, CreatedAt: s.CreatedAt, LastUsed: s.LastUsed, - IsCurrent: s.ID == sess.ID, + IsCurrent: sess != nil && s.ID == sess.ID, }) } diff --git a/Server/api/profile_handler_identity_key_test.go b/Server/api/profile_handler_identity_key_test.go new file mode 100644 index 00000000..ad9bf3ae --- /dev/null +++ b/Server/api/profile_handler_identity_key_test.go @@ -0,0 +1,71 @@ +package api_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/service" +) + +// identityKeyFailStore wraps a real *db.DB and fails only +// UpdateUserIdentityKey, simulating the transient DB error that can strike +// after handleUpdateProfile's profile write has already committed. +type identityKeyFailStore struct { + *db.DB +} + +func (s *identityKeyFailStore) UpdateUserIdentityKey(ctx context.Context, id int64, key *string) error { + return errors.New("simulated identity key write failure") +} + +// A follow-on identity-key failure must not swallow the profile write that +// already committed: the response reports failure, but every other +// connected client still needs the committed username/avatar change, or it +// silently disappears until their next ready (v100). +func TestUpdateProfile_IdentityKeyFailureStillBroadcastsCommittedProfile(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + failingStore := &identityKeyFailStore{database} + svc := service.New(failingStore, limiter) + spy := &userUpdateSpy{} + + r := chi.NewRouter() + api.MountProfileRoutes(r, database, svc, nil, limiter, nil, spy) + + token := profileCreateToken(t, database, "identitykeyuser", 4) + + rr := patchJSON(t, r, "/api/v1/users/me", token, map[string]any{ + "username": "renamedidentitykeyuser", + "identity_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + }) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body = %s", rr.Code, rr.Body.String()) + } + + // The username write committed even though the request reports failure. + user, err := database.GetUserByUsername(context.Background(), "renamedidentitykeyuser") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user == nil { + t.Fatal("expected the username change to have committed despite the identity-key failure") + } + + if len(spy.got) != 1 { + t.Fatalf("BroadcastUserUpdate calls = %d, want 1 (the committed profile, even though the request failed)", len(spy.got)) + } + got := spy.got[0] + if got.Username != "renamedidentitykeyuser" { + t.Errorf("broadcast username = %q, want the committed rename", got.Username) + } + if got.IdentityPublicKey != nil { + t.Errorf("broadcast identity key = %v, want nil (that half never committed)", *got.IdentityPublicKey) + } +} diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index 76bda2bc..8cf75e06 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -455,3 +455,84 @@ func TestUpdateProfile_IdentityKeyInvalid(t *testing.T) { }) } } + +// ─── GET /api/v1/users/me/sessions with an API-token principal ──────────────── + +func TestListSessions_APITokenPrincipal(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + + // One login session exists, but the caller authenticates with an API + // token (nil SessionKey). The sibling DELETE works for this principal; + // the list must too, with no session marked current. + _ = profileCreateToken(t, database, "apisessions", 4) + user, _ := database.GetUserByUsername(context.Background(), "apisessions") + if user == nil { + t.Fatal("user not found") + } + apiTok, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateAPIToken(context.Background(), user.ID, auth.HashToken(apiTok), "ci", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/sessions", nil) + req.Header.Set("Authorization", "Bearer "+apiTok) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("sessions list via API token: status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + var resp struct { + Sessions []struct { + ID int64 `json:"id"` + IsCurrent bool `json:"is_current"` + } `json:"sessions"` + } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if len(resp.Sessions) != 1 { + t.Fatalf("sessions = %d, want 1", len(resp.Sessions)) + } + if resp.Sessions[0].IsCurrent { + t.Error("API-token principal must not mark any session as current") + } +} + +// ─── Rate-limit bucket isolation ───────────────────────────────────────────── + +func TestRateLimit_ProfileUpdatesDoNotConsumePasswordBudget(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "bucketuser", 4) + + // Five profile PATCHes (limit 10/min). If the password endpoint shared + // the same bare-IP bucket, its 5/min budget would now read as exhausted + // with zero password attempts made. + for i := range 5 { + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": "bucketuser", + }) + if rr.Code == http.StatusTooManyRequests { + t.Fatalf("profile PATCH %d unexpectedly rate limited", i) + } + } + + raw, _ := json.Marshal(map[string]string{ + "old_password": "wrong-on-purpose", + "new_password": "NewSecurePass1!", + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/users/me/password", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code == http.StatusTooManyRequests { + t.Fatal("password change 429'd with zero password attempts made — unrelated endpoints share one rate-limit bucket") + } +} diff --git a/Server/api/router.go b/Server/api/router.go index 567f50fe..614cd166 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -49,7 +49,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // unconditionally. r.Use(telemetry.HTTPMiddleware()) r.Use(SecurityHeadersWithTLS(cfg.TLS.Mode)) - r.Use(MaxBodySizeUnless(defaultMaxBodySize, "/api/v1/uploads")) // upload route exempt + r.Use(MaxBodySizeUnless(defaultMaxBodySize, bodyCapExemptPrefixes...)) // Coraza WAF — opt-in via config. if cfg.Server.WAFEnabled { @@ -236,7 +236,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Exposes Go runtime version and LiveKit node IP which aid targeted attacks. r.With(AuthMiddleware(database), RequirePermission(permissions.Administrator), - RateLimitMiddleware(limiter, 5, time.Minute, cfg.Server.TrustedProxies)). + RateLimitMiddleware(limiter, "diag:", 5, time.Minute, cfg.Server.TrustedProxies)). Get("/api/v1/diagnostics/connectivity", handleDiagnosticsConnectivity(cfg, ver, hub)) diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index 371c79df..788881ec 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -90,6 +90,17 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } + // A ban can land inside the partial-token window; the login path + // refuses banned users right after the password compare, so the + // second factor must refuse them too. + if auth.IsEffectivelyBanned(user) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "your account has been suspended", + }) + return + } + secret, decErr := auth.DecryptTOTPSecret(totpKey, *user.TOTPSecret) if decErr != nil { slog.Error("failed to decrypt TOTP secret", "user_id", user.ID, "error", decErr) @@ -287,14 +298,19 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use } pendingStore.Delete(user.ID) - // BUG-108: Revoke all other sessions after 2FA state change. - if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - // Security tail of the 2FA change: once the secret update committed, - // revoking the other sessions must not be aborted by a dead request. - n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) - if n > 0 { - slog.Info("revoked other sessions after totp enable", "user_id", user.ID, "revoked", n) - } + // BUG-108: Revoke all other sessions after 2FA state change. An + // API-token principal has a nil session; keep=0 matches no row, so + // every login session is revoked — same semantics as change-password. + sess, _ := r.Context().Value(SessionKey).(*db.Session) + keepSessionID := int64(0) + if sess != nil { + keepSessionID = sess.ID + } + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, keepSessionID) + if n > 0 { + slog.Info("revoked other sessions after totp enable", "user_id", user.ID, "revoked", n) } slog.Info("totp enabled", "user_id", user.ID) @@ -372,14 +388,19 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim return } - // BUG-108: Revoke all other sessions after 2FA state change. - if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - // Security tail of the 2FA change: once the secret update committed, - // revoking the other sessions must not be aborted by a dead request. - n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) - if n > 0 { - slog.Info("revoked other sessions after totp disable", "user_id", user.ID, "revoked", n) - } + // BUG-108: Revoke all other sessions after 2FA state change. An + // API-token principal has a nil session; keep=0 matches no row, so + // every login session is revoked — same semantics as change-password. + sess, _ := r.Context().Value(SessionKey).(*db.Session) + keepSessionID := int64(0) + if sess != nil { + keepSessionID = sess.ID + } + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, keepSessionID) + if n > 0 { + slog.Info("revoked other sessions after totp disable", "user_id", user.ID, "revoked", n) } slog.Info("totp disabled", "user_id", user.ID) diff --git a/Server/api/totp_handler_test.go b/Server/api/totp_handler_test.go index 5516079b..5c270791 100644 --- a/Server/api/totp_handler_test.go +++ b/Server/api/totp_handler_test.go @@ -402,6 +402,132 @@ func TestDisableTOTP_BlockedByServerPolicy(t *testing.T) { } } +// ─── API-token principals (nil session) and post-password bans ─────────────── + +func TestVerifyTOTP_BannedAfterPasswordStep(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + secret, _ := auth.GenerateTOTPSecret() + hash, _ := auth.HashPassword("Password1!") + uid, _ := database.CreateUser(context.Background(), "banafterpw", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "banafterpw", + "password": "Password1!", + }) + var loginResp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&loginResp) + partialToken, _ := loginResp["partial_token"].(string) + if partialToken == "" { + t.Fatal("expected partial_token from login") + } + + // The ban lands inside the 10-minute partial-token window; the sibling + // login path refuses banned users right after the password compare. + if err := database.BanUser(context.Background(), uid, "test ban", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) + rr = postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken, + map[string]string{"code": code}) + if rr.Code != http.StatusForbidden { + t.Errorf("verify-totp for banned user: status = %d, want 403; body = %s", rr.Code, rr.Body.String()) + } + var verifyResp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&verifyResp) + if verifyResp["token"] != nil { + t.Error("verify-totp issued a session token to a banned user") + } +} + +func TestConfirmTOTP_APITokenPrincipal_RevokesAllSessions(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + sessionToken := loginAndGetToken(t, router, database, "apitotp1", 4) + user, _ := database.GetUserByUsername(context.Background(), "apitotp1") + if user == nil { + t.Fatal("user not found") + } + apiTok, _ := auth.GenerateToken() + if _, err := database.CreateAPIToken(context.Background(), user.ID, auth.HashToken(apiTok), "ci", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", apiTok, + map[string]string{"password": "Password1!"}) + if rr.Code != http.StatusOK { + t.Fatalf("enable via API token: status = %d; body = %s", rr.Code, rr.Body.String()) + } + var enableResp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&enableResp) + secret := extractSecretFromURI(t, enableResp["qr_uri"].(string)) + code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) + + rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", apiTok, + map[string]string{"password": "Password1!", "code": code}) + if rr.Code != http.StatusNoContent { + t.Fatalf("confirm via API token: status = %d; body = %s", rr.Code, rr.Body.String()) + } + + // A 2FA change from a sessionless principal must revoke EVERY login + // session (keep = 0), mirroring the change-password path — not skip + // revocation entirely. + if s, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(sessionToken)); s != nil { + t.Error("login session survived a 2FA enable performed via API token; want all sessions revoked") + } +} + +func TestDisableTOTP_APITokenPrincipal_RevokesAllSessions(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("Password1!") + uid, _ := database.CreateUser(context.Background(), "apitotp2", hash, 4) + apiTok, _ := auth.GenerateToken() + if _, err := database.CreateAPIToken(context.Background(), uid, auth.HashToken(apiTok), "ci", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + // Enable + confirm 2FA via the API token first. + rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", apiTok, + map[string]string{"password": "Password1!"}) + if rr.Code != http.StatusOK { + t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String()) + } + var enableResp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&enableResp) + secret := extractSecretFromURI(t, enableResp["qr_uri"].(string)) + code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) + rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", apiTok, + map[string]string{"password": "Password1!", "code": code}) + if rr.Code != http.StatusNoContent { + t.Fatalf("confirm: status = %d; body = %s", rr.Code, rr.Body.String()) + } + + // A login session created after enrollment must be revoked by the disable. + sessionToken, _ := auth.GenerateToken() + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(sessionToken), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + rr = deleteWithToken(t, router, "/api/v1/users/me/totp", apiTok, + map[string]string{"password": "Password1!"}) + if rr.Code != http.StatusNoContent { + t.Fatalf("disable via API token: status = %d; body = %s", rr.Code, rr.Body.String()) + } + + if s, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(sessionToken)); s != nil { + t.Error("login session survived a 2FA disable performed via API token; want all sessions revoked") + } +} + // ─── Helpers ───────────────────────────────────────────────────────────────── // deleteWithToken sends a DELETE request with a JSON body and auth token. diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 066896da..64b25506 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -1,6 +1,7 @@ package api import ( + "database/sql" "errors" "fmt" "image" @@ -265,6 +266,36 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s return } + // A soft-deleted message's attachments must stop being servable the + // moment the message is deleted — the client shows a tombstone, but + // without this check the file stays reachable by URL forever (no + // sweep can ever reclaim a linked row either, since the only reaper + // requires message_id IS NULL). Checked before the ACL branch so it + // also covers admins, matching the tombstone applying to everyone. + // + // Queried directly rather than through database.GetMessage: that + // wrapper's SELECT list carries every message column, and the + // `deleted` flag is the only one this check needs. + if aa.MessageID != nil { + var deleted bool + deletedErr := database.QueryRowContext(r.Context(), + `SELECT deleted FROM messages WHERE id = ?`, *aa.MessageID).Scan(&deleted) + switch { + case errors.Is(deletedErr, sql.ErrNoRows): + // No message row — leave ACL to decide (unlinked-shaped by now). + case deletedErr != nil: + slog.Error("failed to look up message for attachment", "id", fileID, "error", deletedErr) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "internal server error", + }) + return + case deleted: + http.NotFound(w, r) + return + } + } + // ── Access control ────────────────────────────────────────────── isAdmin := role != nil && permissions.HasAdmin(role.Permissions) diff --git a/Server/api/upload_handler_deleted_message_test.go b/Server/api/upload_handler_deleted_message_test.go new file mode 100644 index 00000000..8b466fca --- /dev/null +++ b/Server/api/upload_handler_deleted_message_test.go @@ -0,0 +1,56 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +// A soft-deleted message's linked attachment must stop being servable — the +// client shows a tombstone, but the file stayed reachable by URL forever +// with no way to reclaim it (v034). +func TestServeFile_LinkedToDeletedMessage_NotFound(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildUploadRouter(database, store, nil) + token := uploadCreateToken(t, database, "deletedmsgowner", 4) // Member role + + content := []byte("attachment content for a message that will be soft-deleted") + rr := doUpload(t, router, token, "file", "willbedeleted.txt", content) + if rr.Code != http.StatusCreated { + t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + fileID := resp["id"].(string) + + if _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil { + t.Fatalf("insert channel: %v", err) + } + var userID int64 + if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'deletedmsgowner'`).Scan(&userID); err != nil { + t.Fatalf("get user id: %v", err) + } + if _, err := database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID); err != nil { + t.Fatalf("insert message: %v", err) + } + if _, err := database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID); err != nil { + t.Fatalf("link attachment: %v", err) + } + + // Sanity: readable before the message is deleted. + rr2 := doServeFile(t, router, fileID, token, nil) + if rr2.Code != http.StatusOK { + t.Fatalf("status before delete = %d, want 200", rr2.Code) + } + + if _, err := database.ExecContext(context.Background(), `UPDATE messages SET deleted = 1 WHERE id = 1`); err != nil { + t.Fatalf("soft-delete message: %v", err) + } + + rr3 := doServeFile(t, router, fileID, token, nil) + if rr3.Code != http.StatusNotFound { + t.Errorf("status after delete = %d, want 404", rr3.Code) + } +} diff --git a/Server/api/waf.go b/Server/api/waf.go index b4766b34..5068bd4c 100644 --- a/Server/api/waf.go +++ b/Server/api/waf.go @@ -185,25 +185,19 @@ func logCRSMatchesAggregate(tx types.Transaction) { slog.Debug("waf: CRS detect-mode matched rule ids", "rule_ids", ids) } -// NewWAFMiddleware creates a Coraza WAF middleware with OWASP CRS rules. -// paranoiaLevel controls rule sensitivity (1=low, 2=default, 3=strict, 4=paranoid). -// Returns nil middleware if WAF creation fails (logged as error, server continues). -// The OWASP CRS layer runs in its default detect mode; use NewWAFMiddlewareCRS -// to select a mode explicitly. -func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { - return NewWAFMiddlewareCRS(paranoiaLevel, CRSModeDetect) -} - -// NewWAFMiddlewareCRS is NewWAFMiddleware with an explicit OWASP CRS layer -// mode ("off" | "detect" | "block", see the CRSMode* constants). Unknown or -// empty modes fall back to detect. +// NewWAFMiddlewareCRS creates a Coraza WAF middleware with OWASP CRS rules. +// paranoiaLevel controls rule sensitivity (1=low, 2=default, 3=strict, +// 4=paranoid); crsMode selects the CRS layer mode ("off" | "detect" | +// "block", see the CRSMode* constants — unknown or empty modes fall back to +// detect). Returns nil middleware if WAF creation fails (logged as error, +// server continues). func NewWAFMiddlewareCRS(paranoiaLevel int, crsMode string) func(http.Handler) http.Handler { return newWAFMiddleware(paranoiaLevel, crsMode, nil) } -// newWAFMiddleware is the implementation behind NewWAFMiddleware / -// NewWAFMiddlewareCRS. onCRSMatch overrides the CRS match logger (used by -// tests to observe detect-mode matches); nil means log via slog. +// newWAFMiddleware is the implementation behind NewWAFMiddlewareCRS. +// onCRSMatch overrides the CRS match logger (used by tests to observe +// detect-mode matches); nil means log via slog. func newWAFMiddleware(paranoiaLevel int, crsMode string, onCRSMatch func(types.MatchedRule)) func(http.Handler) http.Handler { if paranoiaLevel < 1 || paranoiaLevel > 4 { paranoiaLevel = 2 diff --git a/Server/api/waf_test.go b/Server/api/waf_test.go index 4a3138e0..2cd8b2e8 100644 --- a/Server/api/waf_test.go +++ b/Server/api/waf_test.go @@ -32,7 +32,7 @@ func TestHandleWAFInterruption_WritesJSONAndStatus(t *testing.T) { func TestWAFMiddleware_AllowsBenignRequest(t *testing.T) { called := false - middleware := NewWAFMiddleware(2) + middleware := NewWAFMiddlewareCRS(2, CRSModeDetect) handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(http.StatusNoContent) @@ -53,7 +53,7 @@ func TestWAFMiddleware_AllowsBenignRequest(t *testing.T) { func TestWAFMiddleware_InvalidParanoiaLevelStillAllowsBenignRequest(t *testing.T) { called := false - middleware := NewWAFMiddleware(99) + middleware := NewWAFMiddlewareCRS(99, CRSModeDetect) handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(http.StatusNoContent) @@ -73,7 +73,7 @@ func TestWAFMiddleware_InvalidParanoiaLevelStillAllowsBenignRequest(t *testing.T } func TestWAFMiddleware_BlocksScannerUserAgent(t *testing.T) { - middleware := NewWAFMiddleware(2) + middleware := NewWAFMiddlewareCRS(2, CRSModeDetect) handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("downstream handler should not be called for blocked scanner request") })) @@ -91,7 +91,7 @@ func TestWAFMiddleware_BlocksScannerUserAgent(t *testing.T) { func TestWAFMiddleware_PreservesReadableBodyForDownstream(t *testing.T) { const requestBody = `{"message":"hello world"}` - middleware := NewWAFMiddleware(2) + middleware := NewWAFMiddlewareCRS(2, CRSModeDetect) handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/Server/config/config.go b/Server/config/config.go index ef03ccca..c1b5d57f 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -176,15 +176,10 @@ type ServerConfig struct { // DatabaseConfig holds database settings. // -// Type selects the backend: "sqlite" (default, zero-config) or "postgres" -// (community-hub scale, requires a running PostgreSQL server). The Path field -// is only used by sqlite. The remaining fields apply to postgres only. -// -// PostgreSQL support is currently scaffolding-only: the schema, config plumbing, -// and migrations are in place, but the store query layer is gated on the -// in-progress sqlc adoption (Phase A Step 2). Setting Type to "postgres" will -// cause the server to refuse to start with a clear error pointing at the -// follow-up work — see Server/main.go. +// SQLite is the only supported backend. The PostgreSQL scaffolding that once +// motivated the Type field has been removed (see Server/main.go); the field +// survives so an explicit "sqlite" keeps working and anything else fails +// startup with a clear error instead of being silently ignored. type DatabaseConfig struct { // Type selects the database backend. "sqlite" (or empty, which defaults // to it) is the only supported value. @@ -541,6 +536,12 @@ func validateYAML(raw []byte) error { // tls_cert_file -> tls.cert_file // upload_max_size_mb -> upload.max_size_mb func envKeyToKoanf(s string) string { + // event_persistence is the only multi-word section; cutting at the first + // underscore would produce the dead path event.persistence_* and koanf + // would drop the documented override silently. + if rest, ok := strings.CutPrefix(s, "event_persistence_"); ok { + return "event_persistence." + rest + } before, after, ok := strings.Cut(s, "_") if !ok { return s diff --git a/Server/config/config_test.go b/Server/config/config_test.go index dcd6666b..19a1925e 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -480,3 +480,28 @@ func TestLoadUploadBoundaryValues(t *testing.T) { t.Errorf("Upload.MaxSizeMB = %d, want 0", cfg.Upload.MaxSizeMB) } } + +func TestLoadEnvOverride_EventPersistence(t *testing.T) { + // event_persistence is the only multi-word config section; cutting the + // env key at the first underscore produces the dead path + // event.persistence_enabled and the documented override is silently + // dropped (docs/server-configuration.md). + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + // Enabled defaults to true, so override it to false — the meaningful + // direction for proving the env path is alive. + t.Setenv("OWNCORD_EVENT_PERSISTENCE_ENABLED", "false") + t.Setenv("OWNCORD_EVENT_PERSISTENCE_RETENTION_HOURS", "48") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.EventPersistence.Enabled { + t.Error("EventPersistence.Enabled = true, want env override false") + } + if cfg.EventPersistence.RetentionHours != 48 { + t.Errorf("EventPersistence.RetentionHours = %d, want 48", cfg.EventPersistence.RetentionHours) + } +} diff --git a/Server/db/account.go b/Server/db/account.go index acdfe91f..29bfd748 100644 --- a/Server/db/account.go +++ b/Server/db/account.go @@ -5,13 +5,16 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "slices" "strings" + + "github.com/owncord/server/permissions" ) // DeleteAccount anonymises and disables a user account within a single -// transaction. Because the messages, invites, emoji, and sounds tables +// transaction. Because the messages, invites, and emoji tables // reference users(id) with no ON DELETE CASCADE, we cannot simply DELETE // the row. Instead we: // @@ -34,9 +37,13 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error { defer tx.Rollback() //nolint:errcheck // ── Guard: last admin/owner check ──────────────────────────────────── - // Dynamically resolve admin-class role IDs from the roles table. + // Resolve admin-class roles by the canonical criteria — the seeded + // Owner/Admin role IDs plus any custom role holding the Administrator + // bypass bit. Names are user-editable (the Owner can rename the seeded + // Admin role), so a name lookup would silently disable the guard. adminRows, err := tx.QueryContext(ctx, - `SELECT id FROM roles WHERE name IN ('Owner', 'Admin')`, + `SELECT id FROM roles WHERE id IN (?, ?) OR (permissions & ?) != 0`, + permissions.OwnerRoleID, permissions.AdminRoleID, permissions.Administrator, ) if err != nil { return fmt.Errorf("DeleteAccount fetch admin roles: %w", err) @@ -91,12 +98,38 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error { } } + // Snapshot the user's DM channels before the participant rows go away, + // so channels left with zero participants can be removed below — + // LeaveGroupDM's invariant: a participant-less DM channel is an + // unreachable, undeletable row. + var dmChannelIDs []int64 + dmRows, err := tx.QueryContext(ctx, + `SELECT channel_id FROM dm_participants WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("DeleteAccount list dm channels: %w", err) + } + for dmRows.Next() { + var chID int64 + if scanErr := dmRows.Scan(&chID); scanErr != nil { + dmRows.Close() //nolint:errcheck + return fmt.Errorf("DeleteAccount scan dm channel: %w", scanErr) + } + dmChannelIDs = append(dmChannelIDs, chID) + } + dmRows.Close() //nolint:errcheck + if dmRows.Err() != nil { + return fmt.Errorf("DeleteAccount dm channels rows: %w", dmRows.Err()) + } + // ── Purge related data ─────────────────────────────────────────────── stmts := []struct { label string query string }{ {"sessions", `DELETE FROM sessions WHERE user_id = ?`}, + // API tokens authenticate independently of sessions; leaving them + // active would keep the deleted account usable. + {"api_tokens", `UPDATE api_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL`}, {"dm_participants", `DELETE FROM dm_participants WHERE user_id = ?`}, {"dm_open_state", `DELETE FROM dm_open_state WHERE user_id = ?`}, {"reactions", `DELETE FROM reactions WHERE user_id = ?`}, @@ -108,6 +141,69 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error { } } + // Close and, where emptied, remove the deleted user's DM channels. + for _, chID := range dmChannelIDs { + var isGroup bool + if err := tx.QueryRowContext(ctx, + `SELECT is_group FROM channels WHERE id = ?`, chID, + ).Scan(&isGroup); err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue // channel already gone + } + return fmt.Errorf("DeleteAccount dm channel is_group: %w", err) + } + + if !isGroup { + // The purge above removed only this user's dm_participants row, so + // a 1:1 DM with a live other side is untouched: its dm_participants + // row (and the channel) survive, but the survivor's own + // dm_open_state row does too. Left alone that renders as a + // sidebar entry with a blank, unnamed recipient (GetDMParticipantsForUser + // skips the viewer's own row and this user has none left to + // return) that the survivor can still open and send into. Closing + // it for them removes it from their sidebar, same as if they had + // closed it themselves. + if _, err := tx.ExecContext(ctx, + `DELETE FROM dm_open_state WHERE channel_id = ? AND user_id != ?`, + chID, userID, + ); err != nil { + return fmt.Errorf("DeleteAccount close dm for survivor: %w", err) + } + } + + // Hard-delete DM channels the deletion left with zero participants + // (always true for the last member of a group DM; true for a 1:1 DM + // only when the other side had already deleted their own account). + // + // Unlink attachments first: messages.channel_id and + // attachments.message_id both cascade ON DELETE (migrations/001), so + // deleting the channel row destroys the attachment rows too. Those + // rows are the only handle DeleteOrphanedAttachments (the periodic + // sweep in main.go) has on the uploaded files — once the cascade + // removes them the files are stranded on disk forever. Setting + // message_id to NULL first turns them into ordinary orphaned + // attachments the sweep already knows how to reclaim. + if _, err := tx.ExecContext(ctx, + `UPDATE attachments SET message_id = NULL + WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?) + AND EXISTS ( + SELECT 1 FROM channels + WHERE channels.id = ? AND channels.type = 'dm' + AND NOT EXISTS (SELECT 1 FROM dm_participants WHERE channel_id = channels.id) + )`, + chID, chID, + ); err != nil { + return fmt.Errorf("DeleteAccount unlink dm attachments: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM channels WHERE id = ? AND type = 'dm' + AND NOT EXISTS (SELECT 1 FROM dm_participants WHERE channel_id = channels.id)`, + chID, + ); err != nil { + return fmt.Errorf("DeleteAccount empty dm channel: %w", err) + } + } + // Soft-delete messages: mark as deleted and clear content so the rows // remain for conversation continuity but contain no personal data. if _, err := tx.ExecContext(ctx, @@ -146,14 +242,22 @@ const anonymiseUserAttempts = 4 // suffix so no third party can pin the account in place by squatting a // predictable string. func anonymiseUser(ctx context.Context, tx *sql.Tx, userID int64) error { + // ban_expires must be cleared: a stale lapsed temp-ban timestamp would + // make banned=1 read as NOT banned (IsEffectivelyBanned), reviving the + // deleted account for any credential that survives. const anonymise = `UPDATE users - SET username = ?, - password = '', - avatar = NULL, - totp_secret = NULL, - status = 'offline', - banned = 1, - ban_reason = 'account deleted' + SET username = ?, + password = '', + avatar = NULL, + totp_secret = NULL, + display_name = NULL, + about = NULL, + custom_status = NULL, + identity_public_key = NULL, + status = 'offline', + banned = 1, + ban_expires = NULL, + ban_reason = 'account deleted' WHERE id = ?` var lastErr error diff --git a/Server/db/account_test.go b/Server/db/account_test.go index 1481c73e..33affe21 100644 --- a/Server/db/account_test.go +++ b/Server/db/account_test.go @@ -230,6 +230,296 @@ func TestDeleteAccount_SquattedAnonNameStillDeletes(t *testing.T) { } } +// ─── DeleteAccount — canonical admin criterion, tokens, DM cleanup ─────────── + +func TestDeleteAccount_LastAdminGuard_SurvivesRoleRename(t *testing.T) { + database := openMigratedMemory(t) + // The Owner can rename the seeded Admin role (id=2); the guard must key + // on the canonical role IDs, not the display name. + if _, err := database.ExecContext(context.Background(), + `UPDATE roles SET name = 'Staff' WHERE id = 2`); err != nil { + t.Fatalf("rename admin role: %v", err) + } + adminID := seedUser(t, database, "renamedadmin") + setRole(t, database, adminID, 2) + + err := database.DeleteAccount(context.Background(), adminID) + if !errors.Is(err, db.ErrLastAdmin) { + t.Errorf("DeleteAccount(last holder of renamed admin role) = %v, want ErrLastAdmin", err) + } +} + +func TestDeleteAccount_RevokesAPITokensAndPermanentBan(t *testing.T) { + database := openMigratedMemory(t) + uid := seedUser(t, database, "tokenuser") + + tokenHash := "testhash-tokenuser" + if _, err := database.CreateAPIToken(context.Background(), uid, tokenHash, "ci", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + // A previously lapsed temp ban left ban_expires in the past; combined + // with banned=1 that reads as NOT banned (TestIsEffectivelyBanned_*). + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET ban_expires = '2020-01-01 00:00:00' WHERE id = ?`, uid); err != nil { + t.Fatalf("set stale ban_expires: %v", err) + } + + if err := database.DeleteAccount(context.Background(), uid); err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + + if tok, _ := database.GetActiveAPIToken(context.Background(), tokenHash); tok != nil { + t.Error("API token still active after account deletion; want revoked") + } + var banExpires *string + if err := database.QueryRowContext(context.Background(), + `SELECT ban_expires FROM users WHERE id = ?`, uid).Scan(&banExpires); err != nil { + t.Fatalf("read ban_expires: %v", err) + } + if banExpires != nil { + t.Errorf("ban_expires = %q after deletion, want NULL (permanent ban)", *banExpires) + } +} + +func TestDeleteAccount_LastGroupDMParticipant_RemovesChannel(t *testing.T) { + database := openMigratedMemory(t) + userA := seedUser(t, database, "dmlast-a") + userB := seedUser(t, database, "dmlast-b") + userC := seedUser(t, database, "dmlast-c") + + ch, err := database.CreateGroupDMChannel(context.Background(), "grp", []int64{userA, userB, userC}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + if _, err := database.LeaveGroupDM(context.Background(), userB, ch.ID); err != nil { + t.Fatalf("LeaveGroupDM(userB): %v", err) + } + if _, err := database.LeaveGroupDM(context.Background(), userC, ch.ID); err != nil { + t.Fatalf("LeaveGroupDM(userC): %v", err) + } + + // userA is now the last participant; deleting the account must apply + // LeaveGroupDM's invariant — a participant-less DM channel is an + // unreachable, undeletable row and must not survive. + if err := database.DeleteAccount(context.Background(), userA); err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + + var count int + if err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&count); err != nil { + t.Fatalf("count channels: %v", err) + } + if count != 0 { + t.Errorf("group DM channel survived deletion of its last participant; want removed") + } +} + +// TestDeleteAccount_ClearsProfileFields locks the erasure path against a +// regression that leaves user-authored free text (nickname, bio, status +// line) and the E2EE identity key sitting in the row after "deletion" — +// DeleteAccount documents that "all personal data is removed", and an admin +// unban would otherwise republish this text to every client. +func TestDeleteAccount_ClearsProfileFields(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "gina") + + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET display_name = ?, about = ?, custom_status = ?, identity_public_key = ? WHERE id = ?`, + "Gina G.", "just a bio", "in a meeting", "base64keymaterial", userID, + ); err != nil { + t.Fatalf("seed profile fields: %v", err) + } + + if err := database.DeleteAccount(context.Background(), userID); err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + + var displayName, about, customStatus, identityKey *string + if err := database.QueryRowContext(context.Background(), + `SELECT display_name, about, custom_status, identity_public_key FROM users WHERE id = ?`, userID, + ).Scan(&displayName, &about, &customStatus, &identityKey); err != nil { + t.Fatalf("read profile fields: %v", err) + } + if displayName != nil { + t.Errorf("display_name = %q, want NULL", *displayName) + } + if about != nil { + t.Errorf("about = %q, want NULL", *about) + } + if customStatus != nil { + t.Errorf("custom_status = %q, want NULL", *customStatus) + } + if identityKey != nil { + t.Errorf("identity_public_key = %q, want NULL", *identityKey) + } +} + +// TestDeleteAccount_OneOnOneDM_ClosesForSurvivor covers the partner of a 1:1 +// DM whose other side deletes their account. Only the deleting user's own +// dm_participants/dm_open_state rows are removed by the generic purge, so +// without the extra cleanup the channel survives with a recipient nobody can +// resolve — a blank-named sidebar row the survivor could still open and send +// into. DeleteAccount must instead close the DM out of the survivor's +// sidebar, same as the survivor closing it themselves. +func TestDeleteAccount_OneOnOneDM_ClosesForSurvivor(t *testing.T) { + database := openMigratedMemory(t) + alice := seedUser(t, database, "alice-dm") + bob := seedUser(t, database, "bob-dm") + + ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + + if err := database.DeleteAccount(context.Background(), bob); err != nil { + t.Fatalf("DeleteAccount(bob): %v", err) + } + + // The channel itself and Alice's participant row must survive — Alice is + // still a real, undeleted DM participant. + var channelCount int + if err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&channelCount); err != nil { + t.Fatalf("count channels: %v", err) + } + if channelCount != 1 { + t.Errorf("channel count = %d, want 1 (1:1 DM channel must not be hard-deleted while a real participant remains)", channelCount) + } + + // But it must be gone from Alice's open-DM list, not linger as a blank + // recipient she can still send into. + dms, err := database.GetUserDMChannels(context.Background(), alice) + if err != nil { + t.Fatalf("GetUserDMChannels(alice): %v", err) + } + for _, dm := range dms { + if dm.ChannelID == ch.ID { + t.Errorf("channel %d still in alice's open DM list after bob's account deletion", ch.ID) + } + } +} + +// TestDeleteAccount_SurvivingDM_KeepsAttachmentsLinked is the other half of +// the unlink fix: the pre-delete "UPDATE attachments SET message_id = NULL" +// must fire ONLY for channels the purge actually emptied. A 1:1 DM whose +// other side is still a real participant keeps its channel row, so unlinking +// there would hand its attachments to the orphan sweep (message_id IS NULL, +// uploaded_at older than an hour) and silently destroy the survivor's files +// an hour later. The EXISTS guard on the UPDATE is what prevents that. +func TestDeleteAccount_SurvivingDM_KeepsAttachmentsLinked(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + alice := seedUser(t, database, "alice-att") + bob := seedUser(t, database, "bob-att") + + ch, _, err := database.GetOrCreateDMChannel(ctx, alice, bob) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + + // Alice's own message and attachment: they must outlive Bob's deletion. + msgID, err := database.CreateMessage(ctx, ch.ID, alice, "here you go", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id) + VALUES (?, ?, ?, ?, ?, ?)`, + "att-survivor-1", "photo.png", "stored-survivor.png", "image/png", 512, alice, + ); err != nil { + t.Fatalf("seed attachment: %v", err) + } + if n, err := database.LinkAttachmentsToMessage(ctx, msgID, alice, []string{"att-survivor-1"}); err != nil || n != 1 { + t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err) + } + + if err := database.DeleteAccount(ctx, bob); err != nil { + t.Fatalf("DeleteAccount(bob): %v", err) + } + + att, err := database.GetAttachmentByID(ctx, "att-survivor-1") + if err != nil { + t.Fatalf("GetAttachmentByID: %v", err) + } + if att == nil { + t.Fatal("attachment row disappeared although the DM channel still has a live participant") + } + if att.MessageID == nil || *att.MessageID != msgID { + t.Errorf("attachment MessageID = %v, want %d (must stay linked; unlinking hands it to the orphan sweep)", + att.MessageID, msgID) + } +} + +// TestDeleteAccount_EmptiedDMChannel_PreservesAttachmentsForReclaim covers +// the last member of a group DM deleting their account: the channel row is +// hard-deleted and, without unlinking first, the ON DELETE CASCADE from +// channels -> messages -> attachments (migrations/001) destroys the +// attachment rows too — the only handle the orphan sweep +// (DeleteOrphanedAttachments) has on the uploaded files, permanently +// stranding them on disk. DeleteAccount must unlink the attachments before +// the channel delete so the row (and the sweep's ability to reclaim the +// file) survives. +func TestDeleteAccount_EmptiedDMChannel_PreservesAttachmentsForReclaim(t *testing.T) { + database := openMigratedMemory(t) + userA := seedUser(t, database, "dmatt-a") + userB := seedUser(t, database, "dmatt-b") + userC := seedUser(t, database, "dmatt-c") + + ch, err := database.CreateGroupDMChannel(context.Background(), "grp", []int64{userA, userB, userC}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + + msgID, err := database.CreateMessage(context.Background(), ch.ID, userA, "look at this", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if _, err := database.ExecContext(context.Background(), + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id) + VALUES (?, ?, ?, ?, ?, ?)`, + "att-dm-1", "photo.png", "stored-photo.png", "image/png", 1024, userA, + ); err != nil { + t.Fatalf("seed attachment: %v", err) + } + if n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, userA, []string{"att-dm-1"}); err != nil || n != 1 { + t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err) + } + + if _, err := database.LeaveGroupDM(context.Background(), userB, ch.ID); err != nil { + t.Fatalf("LeaveGroupDM(userB): %v", err) + } + if _, err := database.LeaveGroupDM(context.Background(), userC, ch.ID); err != nil { + t.Fatalf("LeaveGroupDM(userC): %v", err) + } + + // userA is now the last participant; deleting the account empties and + // hard-deletes the channel. + if err := database.DeleteAccount(context.Background(), userA); err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + + var channelCount int + if err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&channelCount); err != nil { + t.Fatalf("count channels: %v", err) + } + if channelCount != 0 { + t.Errorf("group DM channel survived deletion of its last participant; want removed") + } + + att, err := database.GetAttachmentByID(context.Background(), "att-dm-1") + if err != nil { + t.Fatalf("GetAttachmentByID: %v", err) + } + if att == nil { + t.Fatal("attachment row was destroyed by the channel-delete cascade; want it unlinked and preserved for the orphan sweep") + } + if att.MessageID != nil { + t.Errorf("attachment MessageID = %v, want nil (unlinked ahead of the cascade)", *att.MessageID) + } +} + // ─── Helper ────────────────────────────────────────────────────────────────── func setRole(t *testing.T, database *db.DB, userID, roleID int64) { diff --git a/Server/db/apitoken_queries_test.go b/Server/db/apitoken_queries_test.go index 20146aa6..1d92daf1 100644 --- a/Server/db/apitoken_queries_test.go +++ b/Server/db/apitoken_queries_test.go @@ -185,3 +185,77 @@ func TestGetOwnerUser(t *testing.T) { t.Fatalf("GetOwnerUser = %+v, want owner id %d", u, ownerID) } } + +// A deleted account is anonymised and permanently banned in place, keeping its +// high-position role row. Without a banned filter that tombstone outranks every +// live admin forever and becomes the default identity for API-token creation, +// minting tokens the auth layer then rejects on every use. +func TestGetOwnerUser_SkipsBannedOwner(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + + bannedOwnerID := seedTokenUser(t, database, "deleted-owner", 1) + adminID := seedTokenUser(t, database, "live-admin", 2) + + if err := database.BanUser(ctx, bannedOwnerID, "account deleted", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + u, err := database.GetOwnerUser(ctx) + if err != nil { + t.Fatalf("GetOwnerUser: %v", err) + } + if u == nil { + t.Fatal("GetOwnerUser = nil, want the live admin") + } + if u.ID == bannedOwnerID { + t.Fatalf("GetOwnerUser returned the banned owner (id %d) — a deleted account must never be the default token identity", u.ID) + } + if u.ID != adminID { + t.Fatalf("GetOwnerUser = id %d, want live admin id %d", u.ID, adminID) + } +} + +// A temporary ban that has already lapsed must not exclude the owner: the query +// mirrors auth.IsEffectivelyBanned, which treats an elapsed ban_expires as not +// banned. Both spellings of ban_expires that the auth layer accepts are covered, +// because ' ' sorts below 'T' and a naive lexical compare reads a same-day +// space-form expiry as lapsed (or, in the other direction, a live ban as lapsed). +func TestGetOwnerUser_LapsedTempBanStaysEligible(t *testing.T) { + for _, tc := range []struct { + name string + expires string + want bool // true = owner should still be returned + }{ + {"lapsed ISO-8601 Z form", time.Now().UTC().Add(-time.Hour).Format("2006-01-02T15:04:05Z"), true}, + {"lapsed space-separated form", time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05"), true}, + {"live ban, ISO-8601 Z form", time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z"), false}, + {"live ban, space-separated form", time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + ownerID := seedTokenUser(t, database, "owner", 1) + adminID := seedTokenUser(t, database, "live-admin", 2) + + if _, err := database.ExecContext(ctx, + `UPDATE users SET banned = 1, ban_expires = ? WHERE id = ?`, tc.expires, ownerID); err != nil { + t.Fatalf("seed temp ban: %v", err) + } + + u, err := database.GetOwnerUser(ctx) + if err != nil { + t.Fatalf("GetOwnerUser: %v", err) + } + if u == nil { + t.Fatal("GetOwnerUser = nil, want a user") + } + if tc.want && u.ID != ownerID { + t.Fatalf("GetOwnerUser = id %d, want owner id %d (lapsed ban must not exclude)", u.ID, ownerID) + } + if !tc.want && u.ID != adminID { + t.Fatalf("GetOwnerUser = id %d, want admin id %d (live ban must exclude the owner)", u.ID, adminID) + } + }) + } +} diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index c0580203..8be937ea 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/owncord/server/db/dbgen" ) @@ -178,15 +179,20 @@ func (d *DB) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (ma } // DeleteOrphanedAttachments atomically removes attachment records where -// message_id IS NULL and uploaded_at is older than the given cutoff time -// string (ISO 8601). Returns the stored_as filenames of deleted records -// so the caller can remove files. +// message_id IS NULL and uploaded_at is older than the given cutoff. Live +// avatars are excluded by the query itself. Returns the stored_as filenames +// of deleted records so the caller can remove files. +// +// The cutoff is a time.Time, not a string, because uploaded_at is stored in +// SQLite's own 'YYYY-MM-DD HH:MM:SS' shape and the comparison is bytewise: +// a caller formatting RFC3339 instead silently collapses the grace period to +// "same date". Formatting it here is what keeps that unrepresentable. // // BUG-132: Uses DELETE ... RETURNING to make select+delete atomic, // preventing a race where an attachment linked between SELECT and DELETE // would have its file deleted while the DB row survives. -func (d *DB) DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) { - files, err := d.q.DeleteOrphanedAttachments(ctx, cutoff) +func (d *DB) DeleteOrphanedAttachments(ctx context.Context, cutoff time.Time) ([]string, error) { + files, err := d.q.DeleteOrphanedAttachments(ctx, cutoff.UTC().Format(sqliteTimeLayout)) if err != nil { return nil, fmt.Errorf("DeleteOrphanedAttachments: %w", err) } diff --git a/Server/db/attachment_unlink_on_cascade_test.go b/Server/db/attachment_unlink_on_cascade_test.go new file mode 100644 index 00000000..f44f1065 --- /dev/null +++ b/Server/db/attachment_unlink_on_cascade_test.go @@ -0,0 +1,72 @@ +package db_test + +import ( + "context" + "testing" + "time" +) + +// migrations/030 changes attachments.message_id from ON DELETE CASCADE to +// ON DELETE SET NULL. Before it, deleting a channel took the whole +// channels -> messages -> attachments cascade and destroyed the attachment +// rows — the only handle DeleteOrphanedAttachments has on the stored files — +// so the uploaded bytes were stranded on disk with nothing left that could +// ever find them. After it, the same delete unlinks the row instead, and the +// existing orphan sweep reclaims the file on its next tick. +// +// This is the structural fix for the whole attachment-orphan family: the +// admin channel delete exercised here, the last leave from a group DM, and +// account deletion emptying a 1:1 DM all reach the same cascade. +func TestAdminDeleteChannel_UnlinksAttachmentsForReclaim(t *testing.T) { + ctx := context.Background() + database := openMigratedMemory(t) + + userID := seedUser(t, database, "cascade-uploader") + chID := seedChannel(t, database, "doomed-channel") + + if err := database.CreateAttachment( + ctx, "att-cascade-1", userID, "file.txt", "stored-cascade.txt", "text/plain", 100, nil, nil, + ); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + msgID, err := database.CreateMessage(ctx, chID, userID, "with attachment", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if n, err := database.LinkAttachmentsToMessage(ctx, msgID, userID, []string{"att-cascade-1"}); err != nil || n != 1 { + t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err) + } + + // Precondition: the attachment is linked, so the sweep must not touch it. + if att, err := database.GetAttachmentByID(ctx, "att-cascade-1"); err != nil || att == nil { + t.Fatalf("precondition: attachment should exist before the delete (err=%v)", err) + } + + if err := database.AdminDeleteChannel(ctx, chID); err != nil { + t.Fatalf("AdminDeleteChannel: %v", err) + } + + // The row must SURVIVE the cascade — that is the whole point. Under the + // old ON DELETE CASCADE it was gone here and the file became unreachable. + att, err := database.GetAttachmentByID(ctx, "att-cascade-1") + if err != nil { + t.Fatalf("GetAttachmentByID after channel delete: %v", err) + } + if att == nil { + t.Fatal("attachment row was destroyed by the channel-delete cascade — its file is now unreclaimable on disk") + } + + // ...and it must now look like an ordinary orphan, so the existing sweep + // reclaims it and reports the stored filename for unlinking from disk. + files, err := database.DeleteOrphanedAttachments(ctx, time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("DeleteOrphanedAttachments: %v", err) + } + if len(files) != 1 || files[0] != "stored-cascade.txt" { + t.Fatalf("orphan sweep returned %v, want exactly [stored-cascade.txt]", files) + } + + if att, err := database.GetAttachmentByID(ctx, "att-cascade-1"); err != nil || att != nil { + t.Fatalf("sweep should have removed the row after reclaiming the file (att=%v err=%v)", att, err) + } +} diff --git a/Server/db/audit_writer.go b/Server/db/audit_writer.go index 3612aa22..b0f5d554 100644 --- a/Server/db/audit_writer.go +++ b/Server/db/audit_writer.go @@ -109,6 +109,23 @@ func (w *AuditWriter) Enqueue(actorID int64, action, targetType string, targetID if w == nil { return } + // run() stops reading w.queue the instant it exits, but the channel keeps + // its buffer and keeps accepting sends — without this check a caller that + // enqueues after Stop has returned (main.go closes the DB right after) + // would land silently in a dead channel, violating D8. w.done closes only + // when run() has actually exited, so this is exact, not a best guess. + select { + case <-w.done: + w.dropped.Add(1) + slog.Error("audit log dropped: writer stopped", + "action", action, + "actor_id", actorID, + "target_type", targetType, + "target_id", targetID, + ) + return + default: + } select { case w.queue <- pendingAudit{actorID: actorID, action: action, targetType: targetType, targetID: targetID, detail: detail}: default: @@ -151,6 +168,26 @@ func (w *AuditWriter) Stop(ctx context.Context) { } // Always wait for the goroutine to exit — never race it against ctx. <-w.done + + // A concurrent Enqueue can win the race between run()'s last drain + // attempt and the close of w.done above: it observes w.done not yet + // closed and sends into w.queue just as run() is exiting, so nothing + // ever reads that entry. Sweep whatever the race stranded here so it is + // dropped loudly (D8) instead of silently. + for { + select { + case a := <-w.queue: + w.dropped.Add(1) + slog.Error("audit log dropped: writer stopped", + "action", a.action, + "actor_id", a.actorID, + "target_type", a.targetType, + "target_id", a.targetID, + ) + default: + return + } + } } // Stats returns lifetime counters. diff --git a/Server/db/audit_writer_test.go b/Server/db/audit_writer_test.go index f6d6e78c..da910863 100644 --- a/Server/db/audit_writer_test.go +++ b/Server/db/audit_writer_test.go @@ -228,6 +228,51 @@ func TestAuditWriter_StopWaitsForGoroutineExit(t *testing.T) { } } +// TestAuditWriter_EnqueueAfterStopDropsLoudly locks D8 ("a drop is never +// silent") against the post-shutdown race: main.go's deferred Stop can +// return while a WS handler on another goroutine is still mid-flight (hub +// GracefulStop and http.Server.Shutdown do not wait for hijacked WebSocket +// conns), so Enqueue can be called after run() has fully exited. Before the +// fix that entry landed silently in the still-buffered, now-unread queue: no +// dropped-counter bump, no log line. +func TestAuditWriter_EnqueueAfterStopDropsLoudly(t *testing.T) { + store := &fakeAuditStore{} + w := db.NewAuditWriter(store, 64, 50, time.Hour) + w.Start(context.Background()) + w.Stop(context.Background()) + + _, droppedBefore, _, _ := w.Stats() + + out := captureLogs(t, func() { + w.Enqueue(9, "post_stop_action", "user", 99, "secret detail") + }) + + _, droppedAfter, _, _ := w.Stats() + if droppedAfter != droppedBefore+1 { + t.Errorf("dropped counter = %d, want %d (post-Stop Enqueue must count as a loud drop)", droppedAfter, droppedBefore+1) + } + for _, want := range []string{ + "audit log dropped", + "action=post_stop_action", + "actor_id=9", + "target_type=user", + "target_id=99", + } { + if !strings.Contains(out, want) { + t.Errorf("post-Stop drop log missing %q; got: %s", want, out) + } + } + if strings.Contains(out, "secret detail") { + t.Errorf("detail string leaked into post-Stop drop log: %s", out) + } + + // The entry must genuinely never reach the store — no goroutine is left + // to read the queue. + if batches, _ := store.snapshot(); batches != 0 { + t.Errorf("post-Stop entry reached the store via %d batch(es); want it dropped, not written", batches) + } +} + // TestAuditWriter_StopDrainsBeforeStoreClose reproduces main.go's LIFO // shutdown ordering (AuditWriter.Stop, then database.Close) and asserts the // fix: because Stop returns only after the goroutine exits, no flush can run diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index e510336f..c0c2986c 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -527,6 +527,16 @@ type MemberSummary struct { // so "who is invisible" is decided in exactly one place. func (m MemberSummary) ForViewer(viewerID int64) MemberSummary { m.Status = StatusForViewer(m.Status, m.ID, viewerID) + // A connected-but-invisible member collapses to "offline" above, but their + // custom_status is a separate column BroadcastStatus never touches. Left + // alone, a viewer sees {status:"offline", custom_status:"<text>"} for that + // member while every genuinely disconnected member is + // {status:"offline", custom_status:null} — the surviving text is a tell + // that the member is actually online. Blank it at the same choke point + // that collapses the status so no other caller has to remember to. + if m.ID != viewerID && m.Status == StatusOffline { + m.CustomStatus = nil + } return m } diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index c04e4b91..879609ee 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -877,3 +877,53 @@ func TestListMembers_IncludesIdentityKey(t *testing.T) { t.Errorf("idkey_none IdentityPublicKey = %v, want nil", *byName["idkey_none"].IdentityPublicKey) } } + +// ─── MemberSummary.ForViewer ────────────────────────────────────────────────── + +// TestMemberSummary_ForViewer_BlanksCustomStatusWhenInvisible locks the +// invisible-presence invariant against a leak: a connected-but-invisible +// member collapses to status "offline" for other viewers, but without also +// blanking custom_status they'd see {status:"offline", custom_status:"<text>"} +// — a tell that distinguishes them from every genuinely disconnected member, +// who reads as {status:"offline", custom_status:null}. +func TestMemberSummary_ForViewer_BlanksCustomStatusWhenInvisible(t *testing.T) { + status := "in a meeting" + m := db.MemberSummary{ID: 1, Username: "ghost", Status: db.StatusInvisible, CustomStatus: &status} + + seen := m.ForViewer(2) // a different viewer + if seen.Status != db.StatusOffline { + t.Fatalf("Status = %q, want %q", seen.Status, db.StatusOffline) + } + if seen.CustomStatus != nil { + t.Errorf("CustomStatus = %q, want nil (must not leak that %d is connected-but-invisible)", *seen.CustomStatus, m.ID) + } +} + +// TestMemberSummary_ForViewer_KeepsOwnCustomStatusWhenInvisible ensures the +// blanking is other-viewer-only: the owner of an invisible status must still +// see their own true custom status, or their own client would render it wrong. +func TestMemberSummary_ForViewer_KeepsOwnCustomStatusWhenInvisible(t *testing.T) { + status := "in a meeting" + m := db.MemberSummary{ID: 1, Username: "ghost", Status: db.StatusInvisible, CustomStatus: &status} + + seen := m.ForViewer(1) // the owner themselves + if seen.Status != db.StatusInvisible { + t.Fatalf("Status = %q, want %q (owner sees the true value)", seen.Status, db.StatusInvisible) + } + if seen.CustomStatus == nil || *seen.CustomStatus != status { + t.Errorf("CustomStatus = %v, want %q", seen.CustomStatus, status) + } +} + +// TestMemberSummary_ForViewer_KeepsCustomStatusWhenOnline ensures the new +// blanking is scoped to offline-appearing rows only — an online member's +// custom status must still reach other viewers. +func TestMemberSummary_ForViewer_KeepsCustomStatusWhenOnline(t *testing.T) { + status := "shipping code" + m := db.MemberSummary{ID: 1, Username: "alice", Status: db.StatusOnline, CustomStatus: &status} + + seen := m.ForViewer(2) + if seen.CustomStatus == nil || *seen.CustomStatus != status { + t.Errorf("CustomStatus = %v, want %q", seen.CustomStatus, status) + } +} diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 4e0367e3..52a55f4e 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -459,7 +459,7 @@ func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) { _ = database.CreateAttachment(context.Background(), "orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil) // Use a cutoff far in the future so the attachment is considered old. - files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -487,7 +487,7 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil) _, _ = database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"linked-1"}) - files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -496,6 +496,51 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { } } +// An avatar is an attachment that is deliberately never linked to a message: +// users.avatar points at it by URL and that reference is what authorizes serving +// it (migration 027). The orphan sweep must therefore not treat it as garbage. +func TestDeleteOrphanedAttachments_KeepsLiveAvatars(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "avatar-owner") + + _ = database.CreateAttachment(context.Background(), "avatar-1", userID, "me.png", "stored-avatar.png", "image/png", 100, nil, nil) + avatarURL := "/api/v1/files/avatar-1" + if err := database.UpdateUserProfile(context.Background(), userID, "avatar-owner", &avatarURL, nil, nil); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + + files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("DeleteOrphanedAttachments: %v", err) + } + if len(files) != 0 { + t.Errorf("expected 0 deletions, got %d (%v) — a live avatar was swept", len(files), files) + } + att, _ := database.GetAttachmentByID(context.Background(), "avatar-1") + if att == nil { + t.Error("live avatar attachment must survive the orphan sweep") + } +} + +// The sweep runs with a one-hour grace period. uploaded_at is written by SQLite +// as 'YYYY-MM-DD HH:MM:SS', so a cutoff in any other shape compares bytewise +// against it and silently collapses the grace window. +func TestDeleteOrphanedAttachments_GracePeriodHoldsSameDay(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "grace-uploader") + + _ = database.CreateAttachment(context.Background(), "fresh-1", userID, "file.txt", "stored-fresh.txt", "text/plain", 100, nil, nil) + + // Exactly what the maintenance loop passes: one hour ago. + files, err := database.DeleteOrphanedAttachments(context.Background(), time.Now().Add(-1*time.Hour)) + if err != nil { + t.Fatalf("DeleteOrphanedAttachments: %v", err) + } + if len(files) != 0 { + t.Errorf("expected 0 deletions, got %d (%v) — an upload inside the grace period was swept", len(files), files) + } +} + func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "cutoff-uploader") @@ -503,7 +548,7 @@ func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { _ = database.CreateAttachment(context.Background(), "future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil) // Cutoff in the past — newly created attachment should NOT be deleted. - files, err := database.DeleteOrphanedAttachments(context.Background(), "2000-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } diff --git a/Server/db/db.go b/Server/db/db.go index 0ee2271c..b478da4e 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -62,6 +62,12 @@ const filePragmas = "_pragma=busy_timeout(5000)" + // wait up to 5s for the writ "&_pragma=mmap_size(268435456)" + "&_pragma=cache_size(-64000)" +// sqliteTimeLayout is how SQLite's own datetime('now') writes a timestamp, and +// therefore the only shape a Go-side cutoff may take when it is compared against +// such a column: the comparison is bytewise TEXT, so RFC3339's 'T' separator +// sorts after the space and quietly turns "older than X" into "any earlier date". +const sqliteTimeLayout = "2006-01-02 15:04:05" + // isMemoryPath reports whether path names an in-memory database // (":memory:", "file::memory:" or any URI carrying mode=memory). func isMemoryPath(path string) bool { diff --git a/Server/db/db_test.go b/Server/db/db_test.go index 1675b952..b34e7be4 100644 --- a/Server/db/db_test.go +++ b/Server/db/db_test.go @@ -114,7 +114,7 @@ func TestMigrateCreatesAllTables(t *testing.T) { expectedTables := []string{ "users", "sessions", "roles", "channels", "channel_overrides", "messages", "attachments", "reactions", "invites", "read_states", - "audit_log", "login_attempts", "settings", "emoji", "sounds", + "audit_log", "login_attempts", "settings", "emoji", } for _, table := range expectedTables { diff --git a/Server/db/dbgen/apitokens.sql.go b/Server/db/dbgen/apitokens.sql.go index 10ce9d44..50a75d35 100644 --- a/Server/db/dbgen/apitokens.sql.go +++ b/Server/db/dbgen/apitokens.sql.go @@ -63,6 +63,9 @@ SELECT id, username, password, avatar, role_id, totp_secret, status, created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, display_name, about, custom_status FROM users +WHERE banned = 0 + OR (ban_expires IS NOT NULL + AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC ` @@ -73,6 +76,14 @@ ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id AS // A :one query already reads a single row via QueryRow, so no LIMIT is needed // (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the // highest-position role first, so that first row is the owner. +// Banned users are excluded: account deletion anonymises the row and sets +// banned = 1 permanently, so without this filter a self-deleted Owner keeps +// outranking every live admin and becomes the default identity for token +// creation forever -- minting tokens the auth layer then 403s on every use. +// The ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause) +// so a lapsed temporary ban stays eligible; the replace() normalises the space +// separator form of ban_expires to 'T' before comparing, because ' ' sorts +// below 'T' and a same-day space-form expiry would otherwise read as lapsed. func (q *Queries) GetOwnerUser(ctx context.Context) (User, error) { row := q.db.QueryRowContext(ctx, getOwnerUser) var i User diff --git a/Server/db/dbgen/attachments.sql.go b/Server/db/dbgen/attachments.sql.go index 757062a8..119a943f 100644 --- a/Server/db/dbgen/attachments.sql.go +++ b/Server/db/dbgen/attachments.sql.go @@ -40,9 +40,19 @@ func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentPara } const deleteOrphanedAttachments = `-- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? RETURNING stored_as +DELETE FROM attachments +WHERE message_id IS NULL + AND uploaded_at < ? + AND NOT EXISTS ( + SELECT 1 FROM users u WHERE u.avatar = '/api/v1/files/' || attachments.id + ) +RETURNING stored_as ` +// Avatars are attachments that are never linked to a message on purpose: the +// users.avatar URL is what keeps them alive and authorizes serving them +// (migration 027). Excluding them here is what stops the sweep from destroying +// every avatar in the instance. idx_users_avatar makes the lookup cheap. func (q *Queries) DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error) { rows, err := q.db.QueryContext(ctx, deleteOrphanedAttachments, uploadedAt) if err != nil { diff --git a/Server/db/dbgen/models.go b/Server/db/dbgen/models.go index 608c8535..50bcc6d9 100644 --- a/Server/db/dbgen/models.go +++ b/Server/db/dbgen/models.go @@ -204,15 +204,6 @@ type Setting struct { Value string `json:"value"` } -type Sound struct { - ID int64 `json:"id"` - Name string `json:"name"` - Filename string `json:"filename"` - DurationMs int64 `json:"durationMs"` - UploadedBy int64 `json:"uploadedBy"` - CreatedAt string `json:"createdAt"` -} - type User struct { ID int64 `json:"id"` Username string `json:"username"` diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index de3f3b26..de64ca3a 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -49,6 +49,10 @@ type Querier interface { DeleteEmoji(ctx context.Context, id int64) (sql.Result, error) DeleteExpiredSessions(ctx context.Context) error DeleteLockout(ctx context.Context, key string) error + // Avatars are attachments that are never linked to a message on purpose: the + // users.avatar URL is what keeps them alive and authorizes serving them + // (migration 027). Excluding them here is what stops the sweep from destroying + // every avatar in the instance. idx_users_avatar makes the lookup cheap. DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (sql.Result, error) DeleteRole(ctx context.Context, id int64) error @@ -100,6 +104,14 @@ type Querier interface { // A :one query already reads a single row via QueryRow, so no LIMIT is needed // (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the // highest-position role first, so that first row is the owner. + // Banned users are excluded: account deletion anonymises the row and sets + // banned = 1 permanently, so without this filter a self-deleted Owner keeps + // outranking every live admin and becomes the default identity for token + // creation forever -- minting tokens the auth layer then 403s on every use. + // The ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause) + // so a lapsed temporary ban stays eligible; the replace() normalises the space + // separator form of ban_expires to 'T' before comparing, because ' ' sorts + // below 'T' and a same-day space-form expiry would otherwise read as lapsed. GetOwnerUser(ctx context.Context) (User, error) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) // Reactors for one (message, emoji) pair, oldest reaction first. The reactions @@ -149,6 +161,11 @@ type Querier interface { ListChannels(ctx context.Context) ([]ListChannelsRow, error) ListEmoji(ctx context.Context) ([]ListEmojiRow, error) ListInvites(ctx context.Context) ([]ListInvitesRow, error) + // The ready payload's member roster. docs/protocol.md documents members[] as + // "All registered users", so this must not silently truncate: the previous + // LIMIT 1000 dropped every member past the first thousand with no has_more + // signal, leaving those users unrenderable and unmentionable on the client + // with nothing to indicate the list was incomplete. ListMembers(ctx context.Context) ([]ListMembersRow, error) ListPlugins(ctx context.Context) ([]Plugin, error) // Highest rank first. Positions are only "unique enough": reorder normalizes diff --git a/Server/db/dbgen/users.sql.go b/Server/db/dbgen/users.sql.go index d351bdd0..231cd2ac 100644 --- a/Server/db/dbgen/users.sql.go +++ b/Server/db/dbgen/users.sql.go @@ -130,7 +130,6 @@ FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 ORDER BY u.username ASC -LIMIT 1000 ` type ListMembersRow struct { @@ -144,6 +143,11 @@ type ListMembersRow struct { CustomStatus *string `json:"customStatus"` } +// The ready payload's member roster. docs/protocol.md documents members[] as +// "All registered users", so this must not silently truncate: the previous +// LIMIT 1000 dropped every member past the first thousand with no has_more +// signal, leaving those users unrenderable and unmentionable on the client +// with nothing to indicate the list was incomplete. func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { rows, err := q.db.QueryContext(ctx, listMembers) if err != nil { diff --git a/Server/db/dm_group_queries_test.go b/Server/db/dm_group_queries_test.go index 0fa656c4..49ce02be 100644 --- a/Server/db/dm_group_queries_test.go +++ b/Server/db/dm_group_queries_test.go @@ -120,6 +120,62 @@ func TestLeaveGroupDM_DeletesChannelOnLastLeave(t *testing.T) { } } +// TestLeaveGroupDM_LastLeavePreservesAttachmentsForReclaim locks the +// attachment-unlink fix: messages.channel_id and attachments.message_id both +// cascade ON DELETE (migrations/001), so deleting the channel row on the last +// leave would otherwise destroy the attachment rows too — the only handle the +// orphan sweep (DeleteOrphanedAttachments) has on the uploaded files, +// stranding them on disk with no query left able to name them. +func TestLeaveGroupDM_LastLeavePreservesAttachmentsForReclaim(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + group, err := database.CreateGroupDMChannel(ctx, "Ephemeral", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + + msgID, err := database.CreateMessage(ctx, group.ID, 1, "look at this", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id) + VALUES (?, ?, ?, ?, ?, ?)`, + "att-leave-1", "photo.png", "stored-photo.png", "image/png", 2048, 1, + ); err != nil { + t.Fatalf("seed attachment: %v", err) + } + if n, err := database.LinkAttachmentsToMessage(ctx, msgID, 1, []string{"att-leave-1"}); err != nil || n != 1 { + t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err) + } + + for _, uid := range []int64{1, 2, 3} { + if _, err := database.LeaveGroupDM(ctx, uid, group.ID); err != nil { + t.Fatalf("LeaveGroupDM(%d): %v", uid, err) + } + } + + ch, err := database.GetChannel(ctx, group.ID) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if ch != nil { + t.Error("channel survived the last participant leaving") + } + + att, err := database.GetAttachmentByID(ctx, "att-leave-1") + if err != nil { + t.Fatalf("GetAttachmentByID: %v", err) + } + if att == nil { + t.Fatal("attachment row was destroyed by the channel-delete cascade; want it unlinked and preserved for the orphan sweep") + } + if att.MessageID != nil { + t.Errorf("attachment MessageID = %v, want nil (unlinked ahead of the cascade)", *att.MessageID) + } +} + func TestSetDMChannelName_RefusesNonDM(t *testing.T) { database := groupDMFixture(t) ctx := context.Background() diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index 4e02f940..649f169b 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -339,6 +339,20 @@ func (d *DB) LeaveGroupDM(ctx context.Context, userID, channelID int64) (deleted return false, fmt.Errorf("LeaveGroupDM count: %w", err) } if remaining == 0 { + // Unlink attachments before the channel delete below: messages.channel_id + // and attachments.message_id both cascade ON DELETE (migrations/001), so + // without this the cascade destroys the attachment rows along with the + // channel — the only handle the orphan sweep (main.go's maintenance + // tick, DeleteOrphanedAttachments) has on the uploaded files, stranding + // them on disk forever. Setting message_id to NULL first turns them + // into ordinary orphaned attachments the sweep already reclaims. + if _, err = tx.ExecContext(ctx, + `UPDATE attachments SET message_id = NULL + WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)`, + channelID, + ); err != nil { + return false, fmt.Errorf("LeaveGroupDM unlink attachments: %w", err) + } if _, err = tx.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, channelID); err != nil { return false, fmt.Errorf("LeaveGroupDM delete channel: %w", err) } diff --git a/Server/db/mention_queries.go b/Server/db/mention_queries.go index 8c8156be..a466b66f 100644 --- a/Server/db/mention_queries.go +++ b/Server/db/mention_queries.go @@ -18,6 +18,27 @@ type mentionExecer interface { // storage-side backstop so a caller cannot widen the fan-out. const maxMentionsPerMessage = 20 +// notBannedClause is the mention resolution's "is this user reachable" test. +// A raw `banned = 0` reads a temp-banned user as unreachable forever: nothing +// clears the column when ban_expires lapses (that happens lazily, at login, +// via auth.IsEffectivelyBanned — see db/account.go's anonymiseUser comment on +// the same split), so a reinstated user could log in and post yet never +// resolve as an @mention target or appear in an @everyone/@here fan-out. This +// mirrors IsEffectivelyBanned's own rule (permanent when ban_expires is NULL, +// lapsed once ban_expires is in the past) so the two never disagree. +// +// The comparison is lexical, so the two ban_expires spellings +// IsEffectivelyBanned accepts must both sort correctly against the reference +// string. BanUser writes ISO-8601 'Z' ("2006-01-02T15:04:05Z"), but the +// SQLite space form ("2006-01-02 15:04:05") is equally accepted there and +// test-locked (auth/helpers_test.go), and a bare ' ' sorts BELOW 'T' — so an +// unnormalised space-form expiry later on the same day would compare as +// already lapsed and fail OPEN, un-hiding a genuinely banned user. replace() +// normalises the separator first; the trailing 'Z' only ever makes the +// reference string longer at an equal instant, which the `<=` already treats +// as lapsed. Those two are the only spellings any writer produces. +const notBannedClause = `(banned = 0 OR (ban_expires IS NOT NULL AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))` + // MentionTarget is a candidate recipient of a mention fan-out: the user id, the // presence status @here filters on, and the role the user holds (so the caller // can apply the ADMINISTRATOR bypass when a per-user channel override would @@ -238,8 +259,8 @@ func (d *DB) GetUserIDsByUsernames(ctx context.Context, usernames []string) (map rows, err := d.reader.QueryContext(ctx, fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input - `SELECT id, username FROM users WHERE banned = 0 AND username IN (%s)`, - strings.Join(placeholders, ",")), + `SELECT id, username FROM users WHERE %s AND username IN (%s)`, + notBannedClause, strings.Join(placeholders, ",")), args..., ) if err != nil { @@ -277,8 +298,8 @@ func (d *DB) ListMentionTargetsByRoles(ctx context.Context, roleIDs []int64) ([] rows, err := d.reader.QueryContext(ctx, fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input - `SELECT id, status, role_id FROM users WHERE banned = 0 AND role_id IN (%s)`, - strings.Join(placeholders, ",")), + `SELECT id, status, role_id FROM users WHERE %s AND role_id IN (%s)`, + notBannedClause, strings.Join(placeholders, ",")), args..., ) if err != nil { @@ -319,8 +340,8 @@ func (d *DB) ListMentionTargetsByUserIDs(ctx context.Context, userIDs []int64) ( rows, err := d.reader.QueryContext(ctx, fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input - `SELECT id, status, role_id FROM users WHERE banned = 0 AND id IN (%s)`, - strings.Join(placeholders, ",")), + `SELECT id, status, role_id FROM users WHERE %s AND id IN (%s)`, + notBannedClause, strings.Join(placeholders, ",")), args..., ) if err != nil { diff --git a/Server/db/mention_queries_test.go b/Server/db/mention_queries_test.go index cfe5d34d..d4d568ed 100644 --- a/Server/db/mention_queries_test.go +++ b/Server/db/mention_queries_test.go @@ -4,6 +4,7 @@ import ( "context" "strconv" "testing" + "time" "github.com/owncord/server/db" ) @@ -216,6 +217,135 @@ func TestGetUserIDsByUsernames_CaseInsensitive(t *testing.T) { } } +// TestGetUserIDsByUsernames_LapsedTempBan_StillResolves locks the "reconverged +// raw column" fix: nothing clears users.banned when a temp ban's ban_expires +// lapses (that's decided lazily, at login, by auth.IsEffectivelyBanned), so a +// raw `banned = 0` filter would leave a reinstated user permanently +// unresolvable as an @mention target even though they can log in and post +// again. +func TestGetUserIDsByUsernames_LapsedTempBan_StillResolves(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + past := time.Now().Add(-1 * time.Hour) + if err := database.BanUser(ctx, 2, "temp ban", &past); err != nil { + t.Fatalf("BanUser: %v", err) + } + + got, err := database.GetUserIDsByUsernames(ctx, []string{"bob"}) + if err != nil { + t.Fatalf("GetUserIDsByUsernames: %v", err) + } + if got["bob"] != 2 { + t.Errorf("bob = %d, want 2 (a lapsed temp ban must not hide the user from mention resolution)", got["bob"]) + } +} + +// TestGetUserIDsByUsernames_ActiveTempBan_Excluded is the complement: a temp +// ban that has NOT yet lapsed must still exclude the user, same as today. +func TestGetUserIDsByUsernames_ActiveTempBan_Excluded(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + future := time.Now().Add(1 * time.Hour) + if err := database.BanUser(ctx, 2, "temp ban", &future); err != nil { + t.Fatalf("BanUser: %v", err) + } + + got, err := database.GetUserIDsByUsernames(ctx, []string{"bob"}) + if err != nil { + t.Fatalf("GetUserIDsByUsernames: %v", err) + } + if _, ok := got["bob"]; ok { + t.Error("actively temp-banned user must not resolve as a mention target") + } +} + +// TestGetUserIDsByUsernames_ActiveTempBan_SQLiteTimeFormat_Excluded pins the +// expiry filter against the second ban_expires spelling auth.IsEffectivelyBanned +// accepts (and that auth/helpers_test.go locks): SQLite's space-separated +// "2006-01-02 15:04:05". The comparison in the SQL filter is lexical and ' ' +// sorts BELOW 'T', so an unnormalised clause reads any same-day space-form +// expiry as already lapsed and fails OPEN — a genuinely banned user back in +// the fan-out. The expiry here is deliberately later on the *same UTC day* so +// only the separator, not the date, can decide the comparison. +func TestGetUserIDsByUsernames_ActiveTempBan_SQLiteTimeFormat_Excluded(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + // End of the current UTC day: still in the future, still the same date, so + // the ' ' vs 'T' separator is the only thing that can flip the comparison. + now := time.Now().UTC() + future := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, time.UTC) + if !future.After(now) { + t.Skip("run within the last second of the UTC day; no same-day future instant exists") + } + if _, err := database.ExecContext(ctx, + `UPDATE users SET banned = 1, ban_reason = 'temp ban', ban_expires = ? WHERE id = ?`, + future.Format("2006-01-02 15:04:05"), 2, + ); err != nil { + t.Fatalf("seed space-format ban_expires: %v", err) + } + + got, err := database.GetUserIDsByUsernames(ctx, []string{"bob"}) + if err != nil { + t.Fatalf("GetUserIDsByUsernames: %v", err) + } + if _, ok := got["bob"]; ok { + t.Error("temp ban stored in SQLite's space-separated time format must still exclude the user") + } +} + +// TestGetUserIDsByUsernames_PermanentBan_Excluded locks the nil-expiry case: +// a permanent ban (ban_expires NULL) must keep excluding the user forever. +func TestGetUserIDsByUsernames_PermanentBan_Excluded(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if err := database.BanUser(ctx, 2, "permanent ban", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + got, err := database.GetUserIDsByUsernames(ctx, []string{"bob"}) + if err != nil { + t.Fatalf("GetUserIDsByUsernames: %v", err) + } + if _, ok := got["bob"]; ok { + t.Error("permanently banned user must not resolve as a mention target") + } +} + +// TestListMentionTargetsByRoles_LapsedTempBan_Included covers the same +// expiry-aware fix on the @everyone/@here fan-out path. +func TestListMentionTargetsByRoles_LapsedTempBan_Included(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + past := time.Now().Add(-1 * time.Hour) + if err := database.BanUser(ctx, 2, "temp ban", &past); err != nil { + t.Fatalf("BanUser: %v", err) + } + + targets, err := database.ListMentionTargetsByRoles(ctx, []int64{4}) + if err != nil { + t.Fatalf("ListMentionTargetsByRoles: %v", err) + } + found := false + for _, tgt := range targets { + if tgt.UserID == 2 { + found = true + } + } + if !found { + t.Error("user with a lapsed temp ban must still appear in the @everyone/@here fan-out") + } +} + func TestListMentionTargetsByRoles(t *testing.T) { database := newMigratedTestDB(t) seedMentionFixture(t, database) diff --git a/Server/db/plugin_queries.go b/Server/db/plugin_queries.go index 0fbea4b5..0b83fc5e 100644 --- a/Server/db/plugin_queries.go +++ b/Server/db/plugin_queries.go @@ -11,22 +11,20 @@ import ( // unchanged. func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { - res, err := d.writer.ExecContext(ctx, + // RETURNING instead of LastInsertId: last_insert_rowid is connection- + // scoped and NOT updated when the upsert takes the DO UPDATE branch, so + // on the shared writer connection a reinstall would return the rowid of + // some unrelated prior INSERT. + var id int64 + err := d.writer.QueryRowContext(ctx, `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?) - ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`, + ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json + RETURNING id`, name, version, manifestJSON, - ) + ).Scan(&id) if err != nil { return 0, fmt.Errorf("InstallPlugin: %w", err) } - id, err := res.LastInsertId() - if err != nil || id == 0 { - // On conflict path LastInsertId may be 0; look up by name. - row := d.reader.QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name) - if scanErr := row.Scan(&id); scanErr != nil { - return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr) - } - } return id, nil } diff --git a/Server/db/plugin_queries_test.go b/Server/db/plugin_queries_test.go index a20c062f..a39e1323 100644 --- a/Server/db/plugin_queries_test.go +++ b/Server/db/plugin_queries_test.go @@ -320,3 +320,28 @@ func TestPluginKVScan(t *testing.T) { t.Errorf("PluginKVScan with a non-matching prefix = %v, want empty", none) } } + +func TestInstallPlugin_ReinstallReturnsCorrectID_AfterOtherWrites(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + + id1, err := database.InstallPlugin(ctx, "rowid-plugin", "1.0", "{}") + if err != nil { + t.Fatalf("InstallPlugin: %v", err) + } + + // Other INSERTs on the shared single writer connection move + // last_insert_rowid past the plugin's id; the upsert's UPDATE branch + // does not reset it. + seedUser(t, database, "rowid-mover-1") + seedUser(t, database, "rowid-mover-2") + seedUser(t, database, "rowid-mover-3") + + id2, err := database.InstallPlugin(ctx, "rowid-plugin", "2.0", "{}") + if err != nil { + t.Fatalf("InstallPlugin reinstall: %v", err) + } + if id2 != id1 { + t.Errorf("reinstall returned id %d, want %d — EnablePlugin/plugin_kv would target a nonexistent row", id2, id1) + } +} diff --git a/Server/db/queries/sqlite/apitokens.sql b/Server/db/queries/sqlite/apitokens.sql index 0197be5e..5615d89e 100644 --- a/Server/db/queries/sqlite/apitokens.sql +++ b/Server/db/queries/sqlite/apitokens.sql @@ -41,8 +41,19 @@ UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?; -- A :one query already reads a single row via QueryRow, so no LIMIT is needed -- (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the -- highest-position role first, so that first row is the owner. +-- Banned users are excluded: account deletion anonymises the row and sets +-- banned = 1 permanently, so without this filter a self-deleted Owner keeps +-- outranking every live admin and becomes the default identity for token +-- creation forever -- minting tokens the auth layer then 403s on every use. +-- The ban_expires arm mirrors auth.IsEffectivelyBanned (and db.notBannedClause) +-- so a lapsed temporary ban stays eligible; the replace() normalises the space +-- separator form of ban_expires to 'T' before comparing, because ' ' sorts +-- below 'T' and a same-day space-form expiry would otherwise read as lapsed. SELECT id, username, password, avatar, role_id, totp_secret, status, created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, display_name, about, custom_status FROM users +WHERE banned = 0 + OR (ban_expires IS NOT NULL + AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC; diff --git a/Server/db/queries/sqlite/attachments.sql b/Server/db/queries/sqlite/attachments.sql index 0a1e401c..4e9820c6 100644 --- a/Server/db/queries/sqlite/attachments.sql +++ b/Server/db/queries/sqlite/attachments.sql @@ -15,5 +15,15 @@ LEFT JOIN channels c ON c.id = m.channel_id WHERE a.id = ?; -- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? RETURNING stored_as; +-- Avatars are attachments that are never linked to a message on purpose: the +-- users.avatar URL is what keeps them alive and authorizes serving them +-- (migration 027). Excluding them here is what stops the sweep from destroying +-- every avatar in the instance. idx_users_avatar makes the lookup cheap. +DELETE FROM attachments +WHERE message_id IS NULL + AND uploaded_at < ? + AND NOT EXISTS ( + SELECT 1 FROM users u WHERE u.avatar = '/api/v1/files/' || attachments.id + ) +RETURNING stored_as; diff --git a/Server/db/queries/sqlite/users.sql b/Server/db/queries/sqlite/users.sql index 147ff156..13221b0a 100644 --- a/Server/db/queries/sqlite/users.sql +++ b/Server/db/queries/sqlite/users.sql @@ -45,14 +45,18 @@ UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?; -- name: UnbanUser :exec UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?; +-- The ready payload's member roster. docs/protocol.md documents members[] as +-- "All registered users", so this must not silently truncate: the previous +-- LIMIT 1000 dropped every member past the first thousand with no has_more +-- signal, leaving those users unrenderable and unmentionable on the client +-- with nothing to indicate the list was incomplete. -- name: ListMembers :many SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key, u.display_name, u.custom_status FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 -ORDER BY u.username ASC -LIMIT 1000; +ORDER BY u.username ASC; -- name: CountUsers :one SELECT COUNT(*) FROM users; diff --git a/Server/main.go b/Server/main.go index d59f979e..a4a90e53 100644 --- a/Server/main.go +++ b/Server/main.go @@ -310,21 +310,29 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer, levelVar *slog.LevelVar) er } // Clean up orphaned attachments (uploaded but never linked to a message). - cutoff := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) - orphanFiles, orphanErr := database.DeleteOrphanedAttachments(bgCtx, cutoff) - if orphanErr != nil { - log.Warn("failed to delete orphaned attachments", "error", orphanErr) - tickFailed = true - } else if len(orphanFiles) > 0 { - // Best-effort file cleanup. - if fileStorage != nil { + // + // Skipped entirely with no file storage configured: the delete is + // atomic (row goes the instant it's selected, by design — see + // db/attachment_queries.go), so with fileStorage nil the returned + // stored_as names — the only remaining handle on those blobs — + // would just be discarded and the files stranded on disk with no + // query left able to name them. Leaving the rows in place keeps + // them reclaimable once storage is available again. + if fileStorage != nil { + cutoff := time.Now().Add(-1 * time.Hour) + orphanFiles, orphanErr := database.DeleteOrphanedAttachments(bgCtx, cutoff) + if orphanErr != nil { + log.Warn("failed to delete orphaned attachments", "error", orphanErr) + tickFailed = true + } else if len(orphanFiles) > 0 { + // Best-effort file cleanup. for _, filename := range orphanFiles { if delErr := fileStorage.Delete(filename); delErr != nil { log.Warn("failed to delete orphan file", "file", filename, "error", delErr) } } + log.Info("cleaned up orphaned attachments", "count", len(orphanFiles)) } - log.Info("cleaned up orphaned attachments", "count", len(orphanFiles)) } if tickFailed { diff --git a/Server/migrations/029_drop_sounds_table.sql b/Server/migrations/029_drop_sounds_table.sql new file mode 100644 index 00000000..e7ddf0c0 --- /dev/null +++ b/Server/migrations/029_drop_sounds_table.sql @@ -0,0 +1,6 @@ +-- Drop the sounds table. It was created in 001_initial_schema.sql for a +-- soundboard feature that was never built: no query, model, sqlc definition, +-- route or WS handler ever referenced it, so every deployment carries it +-- empty (audit finding A-2026-07-13). The client's matching orphan API +-- methods (getSounds/deleteSound) are removed in the same change. +DROP TABLE IF EXISTS sounds; diff --git a/Server/migrations/030_attachments_unlink_on_message_delete.sql b/Server/migrations/030_attachments_unlink_on_message_delete.sql new file mode 100644 index 00000000..97bbd108 --- /dev/null +++ b/Server/migrations/030_attachments_unlink_on_message_delete.sql @@ -0,0 +1,49 @@ +-- Stop cascaded message deletes from stranding uploaded files on disk. +-- +-- attachments.message_id was declared ON DELETE CASCADE in migrations/001, so +-- anything that removes a message row removes its attachment rows with it: +-- deleting a channel (channels -> messages -> attachments), the last member +-- leaving a group DM, and account deletion emptying a 1:1 DM all take that +-- path. Those rows are the ONLY handle DeleteOrphanedAttachments (the periodic +-- sweep in main.go) has on the stored files, so once the cascade removes them +-- the bytes stay on disk with nothing left that can ever find them again. +-- +-- ON DELETE SET NULL turns the same cascade into an unlink: the row survives +-- with message_id NULL and its original uploaded_at, which is exactly the +-- shape the existing orphan sweep already reclaims on its next tick. Avatars +-- stay protected by that sweep's NOT EXISTS users.avatar clause, so they are +-- unaffected. One schema change covers every delete path, including +-- AdminDeleteChannel, with no caller edits. +-- +-- SQLite cannot ALTER a foreign-key action, so this is the standard +-- rebuild-and-rename. attachments is a leaf table (nothing references it), so +-- neither the DROP nor the RENAME can invalidate another table's references, +-- and the copy satisfies foreign_keys=ON because every surviving message_id +-- still points at a live message row. +CREATE TABLE IF NOT EXISTS attachments_v030 ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + width INTEGER, + height INTEGER, + uploader_id INTEGER REFERENCES users(id) +); + +INSERT INTO attachments_v030 (id, message_id, filename, stored_as, mime_type, + size, uploaded_at, width, height, uploader_id) +SELECT id, message_id, filename, stored_as, mime_type, + size, uploaded_at, width, height, uploader_id +FROM attachments; + +DROP TABLE attachments; + +ALTER TABLE attachments_v030 RENAME TO attachments; + +-- Recreate the indexes the rebuild dropped with the old table. These mirror +-- migrations/010 and migrations/019 exactly. +CREATE INDEX IF NOT EXISTS idx_attachments_uploader ON attachments(uploader_id); +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); diff --git a/Server/plugin/host_ui.go b/Server/plugin/host_ui.go index 553abf30..0c1d89df 100644 --- a/Server/plugin/host_ui.go +++ b/Server/plugin/host_ui.go @@ -1,8 +1,10 @@ // Phase C Step 9 — `ui` host capability. // // A plugin that declares the `ui` capability ships HTML/CSS/JS assets and a -// list of tabs. The host serves those assets at /api/v1/plugins/<name>/ui/... -// and the Solid.js client bridge renders each tab inside a sandboxed iframe. +// list of tabs. This file is the host-side half only: AssetHandler and +// RegisterUI are implemented and tested, but no route is mounted for them +// yet and no client bridge exists — wiring them up is part of the pending +// host-function work tracked in sandbox_wazero.go. package plugin diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 0ea86bb9..4b1c3cc0 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -71,6 +71,11 @@ type Instance struct { // module is the wazero compiled module in the wazero-tagged build, or // nil in the default build. module any //nolint:unused // assigned by wazero-tagged build + + // compiled is the wazero CompiledModule behind module. Retained so + // teardown can close it — the shared runtime otherwise keeps every + // compile from every re-activation cycle until process exit. + compiled any //nolint:unused // assigned by wazero-tagged build } // UITabBinding is the public projection of a plugin's declared UI tab, diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index eaa6da52..3de5377c 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -162,9 +162,11 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // winner already handled command binding. r.mu.Unlock() _ = module.Close(ctx) + _ = compiled.Close(ctx) return nil } inst.module = module + inst.compiled = compiled r.mu.Unlock() if inst.Manifest.HasCapability(CapCommands) { for _, cmd := range listExportedCommands(ctx, module) { @@ -189,6 +191,10 @@ func (r *Registry) platformDeactivate(ctx context.Context, inst *Instance) { _ = mod.Close(context.WithoutCancel(ctx)) } inst.module = nil + if compiled, ok := inst.compiled.(wazero.CompiledModule); ok { + _ = compiled.Close(context.WithoutCancel(ctx)) + } + inst.compiled = nil } // invokeCommand calls the plugin's exported `command_dispatch` function @@ -370,10 +376,18 @@ func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) { return } r.mu.Lock() + var staleCompiled any if inst.module == mod { inst.module = nil + // The next dispatch re-activates with a fresh compile; close the + // stale CompiledModule or the runtime retains every one until exit. + staleCompiled = inst.compiled + inst.compiled = nil } r.mu.Unlock() + if compiled, ok := staleCompiled.(wazero.CompiledModule); ok { + _ = compiled.Close(context.Background()) + } } // listExportedCommands calls the plugin's optional `list_commands` export diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 0c01e1c1..7577d423 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -460,3 +460,42 @@ func TestWazeroInvalidWASMFailsActivation(t *testing.T) { t.Fatal("expected EnablePlugin to fail on invalid WASM") } } + +// Every re-activation compiles the module again; without closing the previous +// CompiledModule the shared runtime retains each copy until process exit. +func TestWazeroDeactivateClosesCompiledModule(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}` + writeTestPlugin(t, dir, "hello", manifest, addWASM) + + reg, mem := newWazeroTestRegistry(t, dir) + ctx := context.Background() + if err := reg.LoadAll(ctx); err != nil { + t.Fatalf("LoadAll: %v", err) + } + rows, err := mem.ListPlugins(ctx) + if err != nil || len(rows) != 1 { + t.Fatalf("ListPlugins: rows=%+v err=%v", rows, err) + } + if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil { + t.Fatalf("EnablePlugin: %v", err) + } + + reg.mu.RLock() + inst := reg.plugins[rows[0].ID] + reg.mu.RUnlock() + if inst == nil || inst.module == nil { + t.Fatal("instance not activated") + } + if inst.compiled == nil { + t.Fatal("activation must retain the CompiledModule handle for teardown") + } + + reg.platformDeactivate(ctx, inst) + if inst.module != nil { + t.Error("deactivate left inst.module set") + } + if inst.compiled != nil { + t.Error("deactivate leaked the CompiledModule — re-activation cycles retain every compile") + } +} diff --git a/Server/service/archived_channel_readonly_test.go b/Server/service/archived_channel_readonly_test.go new file mode 100644 index 00000000..368f7c78 --- /dev/null +++ b/Server/service/archived_channel_readonly_test.go @@ -0,0 +1,65 @@ +package service + +import ( + "context" + "errors" + "testing" +) + +// `archived` used to be consulted only by the visibility predicate +// (VisibleChannelIDs / RefreshChannelVisibility), so it hid a channel without +// protecting it: any caller still holding the id — a custom client, or a stock +// client racing the channel_delete that archiving triggers — could keep posting +// into an archive indefinitely, with nobody able to see or moderate the result. +// Archived channels are now read-only. +func TestSendMessage_RefusedInArchivedChannel(t *testing.T) { + ctx := context.Background() + svc, _, database := newMentionFixture(t) + + if _, err := database.ExecContext(ctx, + `UPDATE channels SET archived = 1 WHERE id = 10`); err != nil { + t.Fatalf("archive channel: %v", err) + } + + _, err := svc.SendMessage(ctx, SendMessageParams{ + ChannelID: 10, + UserID: 1, + Username: "alice", + RoleName: "member", + Content: "posting into the archive", + }) + if err == nil { + t.Fatal("SendMessage into an archived channel succeeded — the archive is still writable") + } + if !errors.Is(err, ErrForbidden) { + t.Fatalf("SendMessage error = %v, want ErrForbidden", err) + } +} + +// The gate must be scoped to the archive flag alone: un-archiving restores +// posting, and an ordinary channel is unaffected. +func TestSendMessage_AllowedAfterUnarchive(t *testing.T) { + ctx := context.Background() + svc, _, database := newMentionFixture(t) + + if _, err := database.ExecContext(ctx, + `UPDATE channels SET archived = 1 WHERE id = 10`); err != nil { + t.Fatalf("archive channel: %v", err) + } + if _, err := svc.SendMessage(ctx, SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", Content: "blocked", + }); err == nil { + t.Fatal("precondition: send should be refused while archived") + } + + if _, err := database.ExecContext(ctx, + `UPDATE channels SET archived = 0 WHERE id = 10`); err != nil { + t.Fatalf("unarchive channel: %v", err) + } + + if _, err := svc.SendMessage(ctx, SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", Content: "allowed again", + }); err != nil { + t.Fatalf("SendMessage after unarchive: %v", err) + } +} diff --git a/Server/service/channel.go b/Server/service/channel.go index c0b97b67..1b3f3de2 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -180,33 +180,49 @@ func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, cleaned = nullable(text) } + // Read the stored custom status BEFORE either write, unconditionally. + // custom_status is *string with no omitempty on the wire (see + // presencePayload), so a nil on the broadcast is wire-identical to "the + // user cleared it" — returning nil for a value we merely failed to read + // wipes the text on every connected client while the row still holds it. + // The stored value is needed twice: + // - when the command carries no custom_status field, it is what rides + // along on the broadcast (a plain online -> idle flip must not blank + // everyone else's copy of the text); + // - when it does carry one and the second write below fails after the + // status write has already committed, it is the true DB state the + // broadcast has to report. + // Doing it first means a read failure aborts before anything commits, + // instead of leaving a committed status with nothing truthful to say. + current, readErr := s.st.GetUserByID(ctx, userID) + if readErr != nil || current == nil { + slog.Error("ChannelService.HandlePresenceUpdate: could not read stored custom status", + "err", readErr, "user_id", userID) + return nil, fmt.Errorf("%w: failed to read current custom status", ErrInternal) + } + storedCustomStatus := current.CustomStatus + if err := s.st.UpdateUserStatus(ctx, userID, status); err != nil { slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID) return nil, fmt.Errorf("%w: failed to update status", ErrInternal) } - if customStatus != nil { - if err := s.st.UpdateUserCustomStatus(ctx, userID, cleaned); err != nil { - slog.Error("ChannelService.HandlePresenceUpdate custom status", "err", err, "user_id", userID) - return nil, fmt.Errorf("%w: failed to update custom status", ErrInternal) - } - return cleaned, nil + + if customStatus == nil { + return storedCustomStatus, nil } - // The command carried no custom_status field, so the stored one stands and - // still has to ride along on the broadcast — otherwise a plain - // online -> idle flip would blank everyone else's copy of the text. - // - // A read failure here is deliberately swallowed rather than returned: the - // status is already committed and is about to be broadcast, so reporting - // an error would tell the caller a presence update failed that in fact - // succeeded. The only cost is that this one broadcast omits the text. - user, readErr := s.st.GetUserByID(ctx, userID) - if readErr != nil || user == nil { - slog.Warn("HandlePresenceUpdate: could not read stored custom status", - "err", readErr, "user_id", userID) - return nil, nil //nolint:nilerr,nilnil // status committed; see comment above + if err := s.st.UpdateUserCustomStatus(ctx, userID, cleaned); err != nil { + // The status row is already committed at this point (two independent + // writes, no transaction), so failing the whole update here would + // report total failure — and broadcast nothing — for a presence + // change that in fact partly succeeded, leaving every client + // (sender included) stuck on the old status while the DB has the + // new one. Swallow the write failure and broadcast the value that is + // actually stored, not the unpersisted "cleaned" text. + slog.Error("ChannelService.HandlePresenceUpdate custom status", "err", err, "user_id", userID) + return storedCustomStatus, nil //nolint:nilerr // status committed; broadcast the true stored custom status } - return user.CustomStatus, nil + return cleaned, nil } // HandleChannelFocus processes a channel focus event and updates read state. @@ -230,9 +246,11 @@ func (s *ChannelService) HandleChannelFocus(ctx context.Context, userID, channel return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - // Mark channel as read. + // Mark channel as read. latestID == 0 (no undeleted messages) still + // writes: the upsert is what zeroes mention_count, and a last_read of 0 is + // correct then — any future message id is larger, so unread counts hold. latestID, err := s.st.GetLatestMessageID(ctx, channelID) - if err == nil && latestID > 0 { + if err == nil { _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } diff --git a/Server/service/channel_presence_test.go b/Server/service/channel_presence_test.go new file mode 100644 index 00000000..3c8143ae --- /dev/null +++ b/Server/service/channel_presence_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// faultyStore wraps a real Store and lets a test force specific methods to +// fail, so HandlePresenceUpdate's post-commit failure handling can be +// exercised without depending on a real DB fault. +type faultyStore struct { + Store + failGetUserByID bool + failUpdateUserCustomStatus bool +} + +func (f *faultyStore) GetUserByID(ctx context.Context, id int64) (*db.User, error) { + if f.failGetUserByID { + return nil, errors.New("injected GetUserByID failure") + } + return f.Store.GetUserByID(ctx, id) +} + +func (f *faultyStore) UpdateUserCustomStatus(ctx context.Context, userID int64, customStatus *string) error { + if f.failUpdateUserCustomStatus { + return errors.New("injected UpdateUserCustomStatus failure") + } + return f.Store.UpdateUserCustomStatus(ctx, userID, customStatus) +} + +// A bare status flip (no custom_status field) must read the currently stored +// text BEFORE writing the new status. If that read fails, nothing may +// commit: returning the status write anyway and broadcasting a nil +// custom_status would be wire-identical to "user cleared their status" and +// wipe every client's copy of text the DB still holds. Regression for +// finding v78. +func TestHandlePresenceUpdate_BareStatusReadFailureAbortsBeforeCommit(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + ctx := context.Background() + + if err := database.UpdateUserStatus(ctx, 1, db.StatusOnline); err != nil { + t.Fatalf("seed status: %v", err) + } + text := "on call" + if err := database.UpdateUserCustomStatus(ctx, 1, &text); err != nil { + t.Fatalf("seed custom status: %v", err) + } + + fs := &faultyStore{Store: database, failGetUserByID: true} + svc := NewChannelService(fs, NewPermissionService(database, permissions.NewChecker(database))) + + got, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusIdle, nil, nil) + if !errors.Is(err, ErrInternal) { + t.Fatalf("err = %v, want ErrInternal", err) + } + if got != nil { + t.Fatalf("returned custom status = %v, want nil on abort", *got) + } + + u, gerr := database.GetUserByID(ctx, 1) + if gerr != nil { + t.Fatalf("GetUserByID: %v", gerr) + } + if u.Status != db.StatusOnline { + t.Fatalf("status = %q, want unchanged %q — a failed pre-write read must not let the status commit", u.Status, db.StatusOnline) + } + if u.CustomStatus == nil || *u.CustomStatus != "on call" { + t.Fatalf("custom_status = %v, want unchanged %q", u.CustomStatus, "on call") + } +} + +// When customStatus != nil, UpdateUserStatus and UpdateUserCustomStatus are +// two independent writes. If the second fails after the first commits, the +// handler must not report total failure (which would broadcast nothing for +// a status change that in fact happened) — it must swallow the failure and +// return the true stored custom_status, not the unpersisted intended value. +// Regression for finding v104. +func TestHandlePresenceUpdate_CustomStatusWriteFailureSwallowedAfterStatusCommit(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + ctx := context.Background() + + fs := &faultyStore{Store: database, failUpdateUserCustomStatus: true} + svc := NewChannelService(fs, NewPermissionService(database, permissions.NewChecker(database))) + + text := "in a meeting" + got, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusDND, &text, nil) + if err != nil { + t.Fatalf("HandlePresenceUpdate: %v, want the status commit reported as success", err) + } + if got != nil { + t.Fatalf("returned custom status = %v, want nil (the write never persisted, must not broadcast it)", *got) + } + + u, gerr := database.GetUserByID(ctx, 1) + if gerr != nil { + t.Fatalf("GetUserByID: %v", gerr) + } + if u.Status != db.StatusDND { + t.Fatalf("status = %q, want committed %q", u.Status, db.StatusDND) + } + if u.CustomStatus != nil { + t.Fatalf("custom_status = %v, want unwritten (nil)", *u.CustomStatus) + } +} + +// The swallowed custom-status write failure must broadcast the text that is +// really stored, not nil: a nil custom_status on the wire is indistinguishable +// from "the user cleared it" (presencePayload has no omitempty), so reporting +// nil for a value the DB still holds wipes it on every client. Regression for +// v104's fix re-introducing v78 on its own failure path. +func TestHandlePresenceUpdate_CustomStatusWriteFailureKeepsStoredText(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + ctx := context.Background() + + stored := "on call" + if err := database.UpdateUserCustomStatus(ctx, 1, &stored); err != nil { + t.Fatalf("seed custom status: %v", err) + } + + fs := &faultyStore{Store: database, failUpdateUserCustomStatus: true} + svc := NewChannelService(fs, NewPermissionService(database, permissions.NewChecker(database))) + + text := "in a meeting" + got, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusDND, &text, nil) + if err != nil { + t.Fatalf("HandlePresenceUpdate: %v, want the status commit reported as success", err) + } + if got == nil || *got != stored { + t.Fatalf("returned custom status = %v, want the stored %q — nil would broadcast a bogus clear", got, stored) + } + + u, gerr := database.GetUserByID(ctx, 1) + if gerr != nil { + t.Fatalf("GetUserByID: %v", gerr) + } + if u.CustomStatus == nil || *u.CustomStatus != stored { + t.Fatalf("custom_status = %v, want unchanged %q", u.CustomStatus, stored) + } +} diff --git a/Server/service/datastore.go b/Server/service/datastore.go index d6604c3c..d8e92145 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -177,7 +177,7 @@ type Store interface { CreateAttachment(ctx context.Context, id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error GetAttachmentByID(ctx context.Context, id string) (*db.Attachment, error) GetAttachmentWithChannel(ctx context.Context, id string) (*db.AttachmentAccess, error) - DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) + DeleteOrphanedAttachments(ctx context.Context, cutoff time.Time) ([]string, error) // ── Admin ── UserCount(ctx context.Context) (int64, error) diff --git a/Server/service/dm.go b/Server/service/dm.go index f7577405..02ac88ad 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -7,6 +7,7 @@ import ( "time" "unicode/utf8" + "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/telemetry" ) @@ -53,6 +54,16 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C if err != nil || recipient == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } + // GetUserByID has no banned filter (unlike the lookups that normally + // surface a user to a caller, e.g. ListMembers), and a hand-crafted + // recipient_id naming a deleted/banned account otherwise creates a + // dead-end DM channel plus participant rows for the tombstone user. + // Gated on IsEffectivelyBanned rather than the raw flag so a lapsed + // temporary ban — which login/WS already treat as not-banned — still + // permits the DM once it expires. + if auth.IsEffectivelyBanned(recipient) { + return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) + } blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID) if err != nil { @@ -230,6 +241,12 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID if err != nil || user == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } + // See the matching check in CreateDM: gate on effective ban status, + // not the raw flag, so a lapsed temporary ban does not wrongly + // refuse the group. + if auth.IsEffectivelyBanned(user) { + return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) + } blocked, err := s.st.IsEitherBlocked(ctx, userID, rid) if err != nil { return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) @@ -344,6 +361,13 @@ func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([ if !ok { return nil, fmt.Errorf("%w: not a participant in this DM", ErrForbidden) } + // A ring is a DM interaction like any other sink: without this check a + // blocked user could still make the blocker's client ring (A-2026-08-03). + // Group DMs are exempt inside requireDMNotBlocked, matching every other + // sink — blocks are enforced at group creation instead. + if err := requireDMNotBlocked(ctx, s.st, userID, channelID); err != nil { + return nil, err + } ids, err := s.st.GetDMParticipantIDs(ctx, channelID) if err != nil { diff --git a/Server/service/dm_test.go b/Server/service/dm_test.go new file mode 100644 index 00000000..626c1a21 --- /dev/null +++ b/Server/service/dm_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/owncord/server/db" +) + +// GetUserByID has no banned filter (unlike ListMembers and the other lookups +// that normally surface a user to a caller), so CreateDM/CreateGroupDM must +// gate on ban status themselves or a hand-crafted recipient_id naming a +// deleted/banned account creates a dead-end DM channel and participant rows +// for the tombstone user (v116). + +func TestDMService_CreateDM_RefusesBannedRecipient(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob", Banned: true}) + + svc := NewDMService(database) + _, err := svc.CreateDM(context.Background(), 1, 2) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("CreateDM to a banned recipient = %v, want ErrNotFound", err) + } +} + +// A temporary ban that has already expired must not block the DM: login, +// WS auth and every other gate already treat this user as not-banned +// (auth.IsEffectivelyBanned), so refusing the DM here would be a stricter, +// inconsistent rule. +func TestDMService_CreateDM_AllowsLapsedTemporaryBan(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob", Banned: true}) + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET ban_expires = '2020-01-01 00:00:00' WHERE id = 2`); err != nil { + t.Fatalf("set stale ban_expires: %v", err) + } + + svc := NewDMService(database) + result, err := svc.CreateDM(context.Background(), 1, 2) + if err != nil { + t.Fatalf("CreateDM to a user with a lapsed temporary ban: %v", err) + } + if result.Channel == nil { + t.Fatal("expected a DM channel to be created") + } +} + +func TestDMService_CreateGroupDM_RefusesBannedRecipient(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUser(t, database, &db.User{ID: 3, Username: "carol", Banned: true}) + + svc := NewDMService(database) + _, err := svc.CreateGroupDM(context.Background(), 1, []int64{2, 3}, "") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("CreateGroupDM with a banned recipient = %v, want ErrNotFound", err) + } +} diff --git a/Server/service/harvest_s5_test.go b/Server/service/harvest_s5_test.go new file mode 100644 index 00000000..c762c797 --- /dev/null +++ b/Server/service/harvest_s5_test.go @@ -0,0 +1,126 @@ +package service + +// Internal tests for the 2026-08-06 harvest S5 service findings: role-mutation +// read-check-write serialization and the mention badge stuck behind the +// latestID>0 gate in HandleChannelFocus. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// rendezvousListStore pairs up two concurrent ListRoles calls so both read the +// same snapshot; a serialized caller waits out the timeout alone. This turns +// the create/create position race into a deterministic interleaving. +type rendezvousListStore struct { + Store + meet chan struct{} +} + +func (s *rendezvousListStore) ListRoles(ctx context.Context) ([]*db.Role, error) { + select { + case s.meet <- struct{}{}: + case <-s.meet: + case <-time.After(150 * time.Millisecond): + } + return s.Store.ListRoles(ctx) +} + +// Two concurrent default-position creates must not land on the same position: +// tied positions read as equal rank in every >=/<= hierarchy comparison, so +// CreateRole's read-check-write has to be serialized. +func TestCreateRole_ConcurrentCreatesCannotCollideOnPosition(t *testing.T) { + _, database := newRoleCRUDService(t) + svc := NewRoleService( + &rendezvousListStore{Store: database, meet: make(chan struct{})}, + NewPermissionService(database, permissions.NewChecker(database)), + ) + + var wg sync.WaitGroup + created := make([]*db.Role, 2) + errs := make([]error, 2) + for i, name := range []string{"RaceA", "RaceB"} { + wg.Go(func() { + created[i], errs[i] = svc.CreateRole(context.Background(), 2, RoleInput{Name: new(name)}) + }) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("CreateRole %d: %v", i, err) + } + } + if created[0].Position == created[1].Position { + t.Fatalf("two concurrent creates landed on position %d — tied roles read as equal rank and can never manage each other", created[0].Position) + } +} + +// erroringMembersStore fails exactly the role-member lookup. +type erroringMembersStore struct { + Store +} + +func (erroringMembersStore) ListUserIDsByRole(context.Context, int64) ([]int64, error) { + return nil, context.DeadlineExceeded +} + +// A failed member lookup must be distinguishable from "role has no members": +// the caller decides between per-user eviction and a blanket invalidation on it. +func TestAffectedUserIDs_LookupFailureReportsNotOK(t *testing.T) { + _, database := newRoleCRUDService(t) + svc := NewRoleService(erroringMembersStore{Store: database}, + NewPermissionService(database, permissions.NewChecker(database))) + + if ids, ok := svc.AffectedUserIDs(context.Background(), permissions.MemberRoleID); ok { + t.Fatalf("AffectedUserIDs reported ok on a failed lookup (ids=%v) — the caller would evict nobody", ids) + } +} + +// channel_focus must clear the mention badge even when every message in the +// channel has been soft-deleted (GetLatestMessageID returns 0) — the badge is +// otherwise stuck and reasserted by every ready payload. +func TestHandleChannelFocus_ClearsMentionBadgeWhenAllMessagesDeleted(t *testing.T) { + database := newTestDB(t) + seedRole(t, database, &db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.ReadMessages | permissions.SendMessages, + Position: 1, + }) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + + ctx := context.Background() + msgID, err := database.CreateMessage(ctx, 10, 2, "hey @alice", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if err := database.IncrementMentionCounts(ctx, 10, []int64{1}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + if err := database.DeleteMessage(ctx, msgID, 2, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database))) + if _, err := svc.HandleChannelFocus(ctx, 1, 10); err != nil { + t.Fatalf("HandleChannelFocus: %v", err) + } + + counts, err := database.GetChannelUnreadCounts(ctx, 1) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + if got := counts[10].MentionCount; got != 0 { + t.Fatalf("mention_count = %d after focusing a channel whose messages were all deleted, want 0", got) + } +} diff --git a/Server/service/message.go b/Server/service/message.go index 3cf55c2e..beab5a88 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "html" "unicode/utf8" "github.com/microcosm-cc/bluemonday" @@ -152,12 +153,63 @@ func (s *MessageService) RunBackgroundInlineForTest() { s.bg = func(fn func()) { fn() } } +// sanitizePass is one unescape-sanitize-unescape cycle. Both unescapes are +// load-bearing; do not drop either. +// - Inner: bluemonday's StrictPolicy treats "<img ...>" as inert +// escaped text and lets it through unchanged. Unescaping first turns +// smuggled entity-encoded markup into real markup *before* bluemonday +// sees it, so StrictPolicy actually strips it. +// - Outer: bluemonday writes surviving text tokens through +// html.EscapeString (Sanitize output is always HTML-escaped), so +// without this, plain punctuation like don't/>/"/& would be persisted +// and rendered as literal '/>/"/& entities. +func sanitizePass(s string) string { + return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) +} + +// sanitizeToFixpoint repeats sanitizePass until it stops changing the +// string. One pass is not enough on its own, for two reasons: +// - A multiply-encoded payload like "&lt;script&gt;" only has its +// outermost layer peeled by a single inner unescape, so it survives the +// first pass as still-escaped inert text and the *outer* unescape then +// turns the one remaining layer into a live "<script>". A second pass +// unescapes and strips it for real. +// - bluemonday's StrictPolicy is only self-stable in *escaped* space (its +// tokenizer decodes entities internally while reading a text node, then +// re-escapes uniformly on write, so Sanitize(Sanitize(x)) == Sanitize(x) +// always held, which is why the pre-fix code's fuzz idempotency check +// never caught this). Emitting a literal outer-unescaped "<" breaks +// that self-stability: e.g. bluemonday tag-strips the adversarial input +// "<<script>script>alert(1)<</script>/script>" down to escaped text +// "</script>", and one outer unescape turns that into the literal +// substring "</script>" — inert as *this* pass's output, but a real end +// tag if it were ever sanitized again. Looping to a fixpoint here means +// sanitizeContent's own output is always already stable, so re-running +// it (which the edit path effectively does, on separately-submitted +// content) is a true no-op instead of merely "safe once". +// +// It always terminates: bluemonday only ever removes characters or shortens +// escaped entities back down, so each pass's output length is +// non-increasing. The iteration bound is a defensive backstop pathological +// input can't actually reach, not what makes this safe. +func sanitizeToFixpoint(raw string) string { + s := raw + for i := 0; i <= len(raw); i++ { + next := sanitizePass(s) + if next == s { + return next + } + s = next + } + return s +} + // sanitizeContent validates and sanitizes message content. func sanitizeContent(raw string, allowEmpty bool) (string, error) { if len(raw) > maxMessageLen*4 { return "", fmt.Errorf("%w: message content exceeds maximum length", ErrBadRequest) } - content := sanitizer.Sanitize(raw) + content := sanitizeToFixpoint(raw) if content == "" && !allowEmpty { return "", fmt.Errorf("%w: message content cannot be empty", ErrBadRequest) } diff --git a/Server/service/message_crud.go b/Server/service/message_crud.go index 67281fb0..f9076508 100644 --- a/Server/service/message_crud.go +++ b/Server/service/message_crud.go @@ -45,6 +45,16 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( isDM := ch.Type == "dm" + // Archived channels are read-only. Until now `archived` was consulted only + // by the visibility predicate (VisibleChannelIDs / RefreshChannelVisibility), + // so it hid the channel without protecting it: any caller that still held + // the id — a custom client, or a stock client racing the channel_delete — + // could keep posting into an archive indefinitely. History stays readable; + // only writes are refused. + if !isDM && ch.Archived { + return nil, fmt.Errorf("%w: channel is archived", ErrForbidden) + } + // Permission check. if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil { return nil, err @@ -106,6 +116,19 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( slog.Warn("MessageService.SendMessage: skipped attachments (not owned, already linked, or missing)", "msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked) } + if linked == 0 && content == "" { + // sanitizeContent waived the empty-content check purely on the + // requested attachment count, before any link attempt. None of + // them actually linked (all missing, foreign, or already + // linked — e.g. a retry of a partially-completed send), so the + // row that just committed has no content and no attachments. + // Compensate the same way the linkErr path above does, rather + // than broadcasting a blank message. + if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil { + slog.Error("MessageService.SendMessage DeleteMessage (empty-after-link cleanup)", "err", delErr, "msg_id", msgID) + } + return nil, fmt.Errorf("%w: message content cannot be empty", ErrBadRequest) + } if linked > 0 { attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID}) if attErr != nil { @@ -116,6 +139,25 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } + // Advance the author's own read state past the message they just sent. + // Both unread queries count "messages with id > my read_states row" and + // neither filters by author, so without this an author's own message + // counts as unread to themselves: post in a channel, navigate away, and + // the next `ready` restates it as an unread badge that never clears until + // something else marks the channel read. + // + // Done here rather than by adding an author filter to the two queries so + // the stored read state stays truthful — you have, in fact, seen your own + // message — and so the fix covers DMs and text channels through one path. + // + // Best-effort: the message is already committed and broadcast-bound, so a + // failure here must not fail the send. The worst case is the pre-existing + // stale-badge behaviour, which the next mark_read corrects. + if err := s.st.UpdateReadState(ctx, p.UserID, p.ChannelID, msgID); err != nil { + slog.Warn("MessageService.SendMessage: could not advance author read state", + "err", err, "user_id", p.UserID, "channel_id", p.ChannelID, "msg_id", msgID) + } + result := &SendMessageResult{ MessageID: msgID, Timestamp: msg.Timestamp, diff --git a/Server/service/message_perms.go b/Server/service/message_perms.go index 9e3a7efc..66640337 100644 --- a/Server/service/message_perms.go +++ b/Server/service/message_perms.go @@ -93,7 +93,8 @@ func (s *MessageService) checkSendPermission(ctx context.Context, userID, channe // of DM channelID have blocked each other in either direction. // // It is the single block-check implementation, called from every DM -// interaction sink — send, edit, react, pin and typing. Enforcing it on the +// interaction sink — send, edit, react, pin, typing and call rings +// (DMService.RingTargets). Enforcing it on the // send path alone left a blocked user an open channel to the blocker: editing // an already-sent message fans MessageEditedDMEvent out to every participant, // so arbitrary new text still reached the person who blocked them, and diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 2cf923f8..18ebccc7 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -207,6 +207,51 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { } } +func TestSendMessage_EmptyContentAllAttachmentsSkipped(t *testing.T) { + database := newTestDB(t) + seedRole(t, database, &db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AttachFiles, + Position: 1, + }) + seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"}) + seedUser(t, database, &db.User{ID: 2, Username: "mallory", Status: "online"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(database) + svc := NewMessageService(database, NewPermissionService(database, checker), nil) + + if err := database.CreateAttachment(context.Background(), "att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { + t.Fatal(err) + } + + // Empty content is normally rejected, but sanitizeContent waives that + // check whenever attachment ids are requested. Here every requested id + // misses the link UPDATE (foreign owner, plus a nonexistent id), so the + // send must not silently commit and broadcast a blank message. + result, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", + Content: "", + AttachmentIDs: []string{"att-foreign", "att-missing"}, + }) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("err = %v, want ErrBadRequest", err) + } + if result != nil { + t.Fatalf("result = %v, want nil", result) + } + + rows, err := database.GetMessages(context.Background(), 10, 0, 50) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(rows) != 0 { + t.Fatalf("channel history = %d messages, want 0 (blank message must be compensating-deleted, not visible)", len(rows)) + } +} + func TestSendMessage_EmptyContent(t *testing.T) { svc, _ := newTestMessageService(t) diff --git a/Server/service/profile_fields_test.go b/Server/service/profile_fields_test.go index 0e9f3304..450c6701 100644 --- a/Server/service/profile_fields_test.go +++ b/Server/service/profile_fields_test.go @@ -4,7 +4,10 @@ import ( "context" "errors" "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/owncord/server/db" "github.com/owncord/server/permissions" @@ -59,6 +62,72 @@ func TestUpdateProfile_SetsAndClearsDisplayNameAndAbout(t *testing.T) { } } +// raceDetectingStore wraps a real Store and records whether two GetUserByID +// calls were ever in flight at once, to prove UpdateProfile's read-merge- +// write serializes per user rather than merely happening to avoid a race +// under a particular timing. +type raceDetectingStore struct { + Store + active int32 + overlap int32 +} + +func (r *raceDetectingStore) GetUserByID(ctx context.Context, id int64) (*db.User, error) { + if atomic.AddInt32(&r.active, 1) > 1 { + atomic.AddInt32(&r.overlap, 1) + } + time.Sleep(5 * time.Millisecond) // widen the window a real race would need + defer atomic.AddInt32(&r.active, -1) + return r.Store.GetUserByID(ctx, id) +} + +func TestUpdateProfile_ConcurrentUpdatesSerializePerUser(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + rs := &raceDetectingStore{Store: database} + svc := NewUserService(rs) + ctx := context.Background() + + // Simulates PATCH /users/me (sets display_name) racing + // POST /users/me/avatar (sets about, standing in for the avatar column; + // both calls pass the unrelated field's current value the way the real + // handlers do). Without serialization, whichever call's read lands + // between the other's read and write would revert that other call's + // change when it writes its own stale merge. + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + name := "Ada L." + if _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &name}); err != nil { + t.Errorf("UpdateProfile (display_name): %v", err) + } + }() + go func() { + defer wg.Done() + about := "counts on it" + if _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", About: &about}); err != nil { + t.Errorf("UpdateProfile (about): %v", err) + } + }() + wg.Wait() + + if got := atomic.LoadInt32(&rs.overlap); got != 0 { + t.Errorf("UpdateProfile's read-merge-write overlapped %d times, want 0 (must be serialized per user)", got) + } + + u, err := database.GetUserByID(ctx, 1) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if u.DisplayName == nil || *u.DisplayName != "Ada L." { + t.Errorf("display_name = %v, want %q — must not be reverted by a concurrent update", u.DisplayName, "Ada L.") + } + if u.About == nil || *u.About != "counts on it" { + t.Errorf("about = %v, want %q — must not be reverted by a concurrent update", u.About, "counts on it") + } +} + func TestUpdateProfile_SanitizesAndTrims(t *testing.T) { svc, _ := newUserSvc(t) name := " <b>Ada</b> " diff --git a/Server/service/role.go b/Server/service/role.go index 83e4371e..7eecb4b3 100644 --- a/Server/service/role.go +++ b/Server/service/role.go @@ -6,6 +6,7 @@ import ( "log/slog" "regexp" "strings" + "sync" "github.com/owncord/server/db" "github.com/owncord/server/permissions" @@ -23,6 +24,11 @@ import ( type RoleService struct { st Store perms *PermissionService + // mu serializes the read-check-write mutations: position uniqueness and + // the role cap are enforced against a ListRoles snapshot, not by a DB + // constraint, so two interleaved mutations can both see the same free + // slot. Single-process server — one lock covers every writer. + mu sync.Mutex } // NewRoleService creates a RoleService. @@ -178,6 +184,8 @@ func (s *RoleService) ListRoles(ctx context.Context, actorID int64) ([]RoleWithM // CreateRole creates a role strictly below the actor's own rank. func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInput) (*db.Role, error) { + s.mu.Lock() + defer s.mu.Unlock() actor, err := s.actorRole(ctx, actorID) if err != nil { return nil, err @@ -265,6 +273,8 @@ func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInpu // the caller uses that to decide between a cheap roles_update broadcast and a // full visibility re-sync. func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in RoleInput) (updated *db.Role, permsChanged bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() actor, err := s.actorRole(ctx, actorID) if err != nil { return nil, false, err @@ -305,6 +315,20 @@ func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in if err := validatePosition(actor, position); err != nil { return nil, false, err } + // Positions must stay unique (see CreateRole): tied positions read + // as equal rank in every >=/<= hierarchy comparison. Moving onto a + // slot another role holds is refused; re-stating our own is fine. + if position != role.Position { + existing, err := s.st.ListRoles(ctx) + if err != nil { + return nil, false, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + } + for _, rl := range existing { + if rl.ID != role.ID && rl.Position == position { + return nil, false, fmt.Errorf("%w: position %d is already used by another role", ErrBadRequest, position) + } + } + } } if err := s.st.UpdateRole(ctx, role.ID, name, color, perms, position); err != nil { @@ -329,6 +353,8 @@ func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in // default role. Returns the deleted role, the fallback its members landed on, // and the ids of those members so the caller can invalidate and re-sync them. func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (deleted, fallback *db.Role, movedUserIDs []int64, err error) { + s.mu.Lock() + defer s.mu.Unlock() actor, err := s.actorRole(ctx, actorID) if err != nil { return nil, nil, nil, err @@ -392,6 +418,8 @@ func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (de // the actor (N < actor.Position, enforced by maxRoles), and never collide with // the untouched roles above the actor. func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedIDs []int64) ([]*db.Role, error) { + s.mu.Lock() + defer s.mu.Unlock() actor, err := s.actorRole(ctx, actorID) if err != nil { return nil, err @@ -445,15 +473,16 @@ func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedID return updated, nil } -// AffectedUserIDs returns the ids of the users holding roleID. Handlers use it -// to invalidate exactly the permission-cache entries a role edit touches. -func (s *RoleService) AffectedUserIDs(ctx context.Context, roleID int64) []int64 { +// AffectedUserIDs returns the ids of the users holding roleID, and whether the +// lookup succeeded. Handlers use it to invalidate exactly the permission-cache +// entries a role edit touches; on ok=false the caller must fall back to a +// blanket invalidation — a nil list treated as "nobody" silently leaves stale +// masks in place. +func (s *RoleService) AffectedUserIDs(ctx context.Context, roleID int64) ([]int64, bool) { ids, err := s.st.ListUserIDsByRole(ctx, roleID) if err != nil { - // The caller falls back to a blanket invalidation; a partial list here - // would silently leave stale masks in place. slog.Warn("role service: failed to list role members", "role_id", roleID, "err", err) - return nil + return nil, false } - return ids + return ids, true } diff --git a/Server/service/role_test.go b/Server/service/role_test.go index 363aad7d..3d089d23 100644 --- a/Server/service/role_test.go +++ b/Server/service/role_test.go @@ -233,6 +233,30 @@ func TestCreateRole_CannotGrantUnheldBit(t *testing.T) { // ─── Update ────────────────────────────────────────────────────────────────── +// An explicit position already held by ANOTHER role is refused on update just +// as on create: tied positions read as equal rank in every >=/<= hierarchy +// comparison, silently breaking one role's authority over the other's members. +func TestUpdateRole_RejectsExplicitPositionCollision(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // Owner moves Moderator (60) onto Member's slot (40). + _, _, err := svc.UpdateRole(context.Background(), 1, permissions.ModeratorRoleID, + RoleInput{Position: new(40)}) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("collision err = %v, want ErrBadRequest", err) + } +} + +// Re-stating a role's own current position is not a collision. +func TestUpdateRole_AllowsKeepingOwnPosition(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + if _, _, err := svc.UpdateRole(context.Background(), 1, permissions.ModeratorRoleID, + RoleInput{Position: new(60)}); err != nil { + t.Fatalf("same-position update: %v", err) + } +} + func TestUpdateRole_PartialBodyLeavesOtherFields(t *testing.T) { svc, _ := newRoleCRUDService(t) @@ -582,12 +606,12 @@ func TestListRoles_CarriesMemberCounts(t *testing.T) { func TestAffectedUserIDs(t *testing.T) { svc, _ := newRoleCRUDService(t) - ids := svc.AffectedUserIDs(context.Background(), permissions.MemberRoleID) - if len(ids) != 2 { - t.Errorf("AffectedUserIDs = %v, want 2 members", ids) + ids, ok := svc.AffectedUserIDs(context.Background(), permissions.MemberRoleID) + if !ok || len(ids) != 2 { + t.Errorf("AffectedUserIDs = %v ok=%v, want 2 members", ids, ok) } - if got := svc.AffectedUserIDs(context.Background(), 9999); len(got) != 0 { - t.Errorf("AffectedUserIDs(missing) = %v, want empty", got) + if got, ok := svc.AffectedUserIDs(context.Background(), 9999); !ok || len(got) != 0 { + t.Errorf("AffectedUserIDs(missing) = %v ok=%v, want empty and ok", got, ok) } } diff --git a/Server/service/sanitize_content_fuzz_test.go b/Server/service/sanitize_content_fuzz_test.go index b9c16d19..8011eae7 100644 --- a/Server/service/sanitize_content_fuzz_test.go +++ b/Server/service/sanitize_content_fuzz_test.go @@ -11,33 +11,42 @@ import ( // living inside an actual surviving tag, case-insensitively, tolerating // whitespace around the '='. // -// The check is deliberately tag-scoped (requires a preceding unclosed '<') -// rather than a bare `\bon\w+\s*=` substring match: bluemonday's -// StrictPolicy strips every tag, so the only way "onerror=" et al. can -// survive into `out` is as inert plain text a user actually typed (e.g. a -// message that reads "use onClick= to bind a handler") — that text renders -// as a text node, not as a live attribute, so it is not an active-content -// sink. A bare substring match flags that benign case as a false positive -// (confirmed via fuzzing: seed "on0=" round-trips unchanged through -// sanitizeContent and is not exploitable). Requiring tag context is what -// actually distinguishes "typed the word onclick=" from "smuggled a live -// onclick attribute". -var onEventAttr = regexp.MustCompile(`(?i)<[^>]*\bon\w+\s*=`) +// The check is deliberately tag-scoped rather than a bare `\bon\w+\s*=` +// substring match, but it is scoped to a *tag-like* start specifically — +// `<` immediately followed by a letter or '/' — not just any `<`. +// sanitizeContent now unescapes bluemonday's output, so a literal '<' CAN +// survive into `out` as plain text a user actually typed (e.g. "5 > 3 && 2 +// < 4" round-trips unchanged). But sanitizeToFixpoint reruns +// unescape-sanitize-unescape until the result stops changing, and a '<' +// followed by a letter (start-tag open) or '/' (end-tag open) is exactly +// the shape bluemonday's tokenizer treats as real markup and strips on the +// next pass — so that adjacency can never be present at a fixpoint. A '<' +// followed by anything else (space, digit, punctuation) isn't a tag +// production at all and is genuinely inert (e.g. a message that reads "use +// onClick= to bind a handler", or "on0=", confirmed via fuzzing to +// round-trip unexploitably) — not a live attribute, so requiring the +// tag-like start is what actually distinguishes "typed the word onclick=" +// from "smuggled a live onclick attribute", now that plain '<' is no longer +// itself proof of nothing dangerous. +var onEventAttr = regexp.MustCompile(`(?i)<[a-z/][^>]*\bon\w+\s*=`) // jsURLInTag matches a javascript: (or similar) pseudo-scheme living inside // an actual surviving tag's attribute — the only shape that is an active -// sink. Like onEventAttr, this is tag-scoped rather than a bare substring -// match: bluemonday's StrictPolicy strips every tag (and HTML-escapes any -// stray '<'), so "javascript:" surviving into `out` at all means it arrived -// as inert plain text (e.g. a message that reads "the demo used -// javascript:void(0) links") — not as a live href. Fuzzing confirmed the -// bare-substring version false-positives on exactly that case (seed -// "jAvAsCript:0"), and the client's own markdown renderer independently -// refuses to autolink a javascript: pseudo-URL (see +// sink. Like onEventAttr, this requires a tag-like start ('<' followed by a +// letter or '/'), not just any '<': since sanitizeContent's outer unescape +// can now leave a literal '<' in inert plain text, a bare '<[^>]*' scope +// would false-positive on typed text like "5 < 10, javascript:void(0)". +// sanitizeToFixpoint's repeated unescape-sanitize-unescape passes guarantee +// a '<' immediately followed by a letter or '/' cannot survive — that shape +// is real markup to bluemonday's tokenizer and gets stripped on the next +// pass — so this pattern only matches the still-impossible live-tag case. +// Fuzzing confirmed the bare-substring version false-positives on plain +// text (seed "jAvAsCript:0"), and the client's own markdown renderer +// independently refuses to autolink a javascript: pseudo-URL (see // tauri-client/tests/unit/content-markdown.test.ts, "does not autolink a // javascript: pseudo-URL"), so plain-text "javascript:" is not exploitable // through any known rendering path. -var jsURLInTag = regexp.MustCompile(`(?i)<[^>]*\bjavascript:`) +var jsURLInTag = regexp.MustCompile(`(?i)<[a-z/][^>]*\bjavascript:`) // FuzzSanitizeContent hammers sanitizeContent with untrusted message content // looking for a case where the "strip everything" bluemonday policy still diff --git a/Server/service/sanitize_content_test.go b/Server/service/sanitize_content_test.go new file mode 100644 index 00000000..e95230cf --- /dev/null +++ b/Server/service/sanitize_content_test.go @@ -0,0 +1,65 @@ +package service + +import ( + "strings" + "testing" +) + +// TestSanitizeContent_PlainTextRoundTrip proves that plain-text punctuation +// survives sanitizeContent unchanged instead of coming back HTML-escaped. +// bluemonday's StrictPolicy writes text tokens through html.EscapeString, so +// without an outer html.UnescapeString, a stored/broadcast message would show +// literal '/>/"/& entities to every client — and a quoted line +// would no longer start with the literal ">" the markdown blockquote regex +// requires. +func TestSanitizeContent_PlainTextRoundTrip(t *testing.T) { + cases := []string{ + `don't > quote "this" & that`, + `a & b`, + `5 > 3 && 2 < 4`, + `> quoted`, + } + for _, in := range cases { + out, err := sanitizeContent(in, false) + if err != nil { + t.Fatalf("sanitizeContent(%q) unexpected error: %v", in, err) + } + if out != in { + t.Fatalf("sanitizeContent(%q) = %q, want unchanged plain text", in, out) + } + } +} + +// TestSanitizeContent_EntitySmugglingBlocked is the security regression test +// for the inner html.UnescapeString: an attacker can encode markup as HTML +// entities so it reaches bluemonday as inert text (which StrictPolicy would +// leave alone), then rely on a naive outer-only unescape to turn it into live +// markup after sanitization. Unescaping BEFORE sanitizing means bluemonday +// sees real markup and strips it, so the smuggled payload must not survive as +// an active <script>/<img>/on-event sink. +func TestSanitizeContent_EntitySmugglingBlocked(t *testing.T) { + cases := []string{ + "<script>alert(1)</script>", + "<img src=x onerror=alert(1)>", + "&lt;script&gt;", + } + for _, in := range cases { + // allowEmpty=true: some of these payloads sanitize down to nothing + // (the whole point — the tag and its content are stripped), which is + // not itself a failure. + out, err := sanitizeContent(in, true) + if err != nil { + t.Fatalf("sanitizeContent(%q) unexpected error: %v", in, err) + } + lower := strings.ToLower(out) + if strings.Contains(lower, "<script") { + t.Fatalf("sanitizeContent(%q) = %q: live <script> sink survived", in, out) + } + if strings.Contains(lower, "<img") { + t.Fatalf("sanitizeContent(%q) = %q: live <img> sink survived", in, out) + } + if strings.Contains(lower, "onerror=") { + t.Fatalf("sanitizeContent(%q) = %q: live on-event attribute survived", in, out) + } + } +} diff --git a/Server/service/send_advances_read_state_test.go b/Server/service/send_advances_read_state_test.go new file mode 100644 index 00000000..91004e5e --- /dev/null +++ b/Server/service/send_advances_read_state_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "testing" +) + +// Both unread queries — GetChannelUnreadCounts (text channels) and +// GetUserDMChannels (DMs) — count "messages with id greater than my +// read_states row" and neither filters by author. Nothing else advanced the +// sender's read state, so an author's own message counted as unread to +// themselves: post, navigate away, and the next `ready` restated it as an +// unread badge that never cleared until something else marked the channel +// read. SendMessage now advances the author's read state past the message it +// just committed. +func TestSendMessage_AdvancesAuthorReadState(t *testing.T) { + ctx := context.Background() + svc, _, database := newMentionFixture(t) + + const author = int64(1) + const other = int64(2) + + res := sendAs(t, svc, author, "my own message") + + counts, err := database.GetChannelUnreadCounts(ctx, author) + if err != nil { + t.Fatalf("GetChannelUnreadCounts(author): %v", err) + } + if got := counts[10].UnreadCount; got != 0 { + t.Fatalf("author unread = %d, want 0 — your own message must not read back as unread to you", got) + } + + // The read state must land exactly on the sent message, not merely be + // non-zero: a value past it would swallow a later message from someone + // else, which is a worse bug than the one being fixed. + if got := counts[10].LastMessageID; got != res.MessageID { + t.Fatalf("author last_msg_id = %d, want %d", got, res.MessageID) + } + + // Everyone else must still see it as unread — this fix must not suppress + // the badge for the recipients it exists for. + otherCounts, err := database.GetChannelUnreadCounts(ctx, other) + if err != nil { + t.Fatalf("GetChannelUnreadCounts(other): %v", err) + } + if got := otherCounts[10].UnreadCount; got != 1 { + t.Fatalf("recipient unread = %d, want 1 — a message from someone else is still unread", got) + } +} + +// A message that arrives AFTER the author's own send must still register as +// unread for the author: advancing the read state on send must move it to the +// sent message, never past it. +func TestSendMessage_AuthorStillSeesLaterMessagesAsUnread(t *testing.T) { + ctx := context.Background() + svc, _, database := newMentionFixture(t) + + const author = int64(1) + const other = int64(2) + + sendAs(t, svc, author, "mine") + sendAs(t, svc, other, "theirs") + + counts, err := database.GetChannelUnreadCounts(ctx, author) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + if got := counts[10].UnreadCount; got != 1 { + t.Fatalf("author unread = %d, want 1 (only the reply from the other user)", got) + } +} diff --git a/Server/service/user.go b/Server/service/user.go index 7f4ebed0..80e109f4 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -10,12 +10,14 @@ import ( "unicode/utf8" "github.com/owncord/server/db" + "github.com/owncord/server/syncutil" "github.com/owncord/server/telemetry" ) // UserService handles user profile and session operations. type UserService struct { - st Store + st Store + profileLocks keyedMutex } // NewUserService creates a UserService. @@ -23,6 +25,36 @@ func NewUserService(st Store) *UserService { return &UserService{st: st} } +// keyedMutex hands out a per-key lock so unrelated keys never contend, while +// operations on the same key serialize. UpdateProfile uses one keyed by user +// ID: it is an unsynchronized read-merge-write (GetUserByID, merge the +// patch, UpdateUserProfile), and PATCH /users/me can race POST +// /users/me/avatar for the same user — without serialization, the loser's +// write commits columns merged against a pre-race snapshot, silently +// reverting whatever the winner just changed. Entries are never removed; +// the key space is bounded by distinct user IDs, not by request rate. +type keyedMutex struct { + mu syncutil.Mutex + locks map[int64]*syncutil.Mutex +} + +// lock acquires the per-key lock and returns a func to release it. +func (k *keyedMutex) lock(key int64) func() { + k.mu.Lock() + if k.locks == nil { + k.locks = make(map[int64]*syncutil.Mutex) + } + l, ok := k.locks[key] + if !ok { + l = &syncutil.Mutex{} + k.locks[key] = l + } + k.mu.Unlock() + + l.Lock() + return l.Unlock +} + // AvatarFileURL is the server-relative path an uploaded avatar is served from. // It is the ordinary attachment route: the upload handler writes an attachment // row and points users.avatar here, and handleServeFile admits an unlinked @@ -71,8 +103,15 @@ func nullable(v string) *string { // cleanText strips HTML and trims a free-text profile field. Both the profile // PATCH and the presence path run values through it before any bound check, so // a payload cannot buy length with markup that is about to be stripped anyway. +// +// Uses sanitizeToFixpoint (message.go), not a bare sanitizer.Sanitize call: +// display name, about, custom status, and DM group names all render through +// the client's textContent-only path (same as message content), so a plain +// sanitizer.Sanitize call would persist and display literal '/>/& +// entities for ordinary punctuation instead of the characters typed — the +// exact bug sanitizeToFixpoint fixes for message content. func cleanText(v string) string { - return strings.TrimSpace(sanitizer.Sanitize(v)) + return strings.TrimSpace(sanitizeToFixpoint(v)) } // resolveOptional picks the column value for one nullable text field: the @@ -107,7 +146,15 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro // The update writes every column, so a partial patch has to be merged // against the current row first — otherwise setting only a display name - // would silently clear the about text. + // would silently clear the about text. That read-merge-write must be + // serialized per user: PATCH /users/me and POST /users/me/avatar both + // land here for the same account, and without a lock the second call's + // read can land between the first call's read and write, so its merge + // (built from the pre-race row) silently reverts the first call's change + // when it writes. + unlock := s.profileLocks.lock(userID) + defer unlock() + current, err := s.st.GetUserByID(ctx, userID) if err != nil || current == nil { return nil, fmt.Errorf("%w: user not found", ErrNotFound) diff --git a/Server/storage/storage.go b/Server/storage/storage.go index 53028e11..a4bce227 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -128,10 +128,16 @@ func (s *Storage) Save(uuid string, r io.Reader) (int64, error) { if err != nil { return 0, fmt.Errorf("creating file %s: %w", dst, err) } - closed := false + // Any failure after this point must remove the partial file: the orphan + // sweep is DB-row-driven, so a file without a DB row is never reclaimed. + // Close before remove — Windows cannot delete an open file. + success := false defer func() { - if !closed { - _ = f.Close() + _ = f.Close() + if !success { + if removeErr := os.Remove(dst); removeErr != nil { + slog.Error("storage: failed to remove partial file", "path", dst, "err", removeErr) + } } }() @@ -147,17 +153,13 @@ func (s *Storage) Save(uuid string, r io.Reader) (int64, error) { if written == maxBytes { var probe [1]byte if n, _ := full.Read(probe[:]); n > 0 { - _ = f.Close() - closed = true - if removeErr := os.Remove(dst); removeErr != nil { - slog.Error("storage: failed to remove oversized file", "path", dst, "err", removeErr) - } return 0, fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB) } } if syncErr := f.Sync(); syncErr != nil { return 0, fmt.Errorf("syncing file %s: %w", dst, syncErr) } + success = true return written, nil } diff --git a/Server/storage/storage_test.go b/Server/storage/storage_test.go index 401aefe9..ec55694a 100644 --- a/Server/storage/storage_test.go +++ b/Server/storage/storage_test.go @@ -449,6 +449,38 @@ func (f *failReader) Read([]byte) (int, error) { return 0, errors.New("simulated read error") } +// midCopyFailReader serves a valid header, then fails — reaching the +// copy-phase error branch after the destination file already exists +// (a write-side disk error like ENOSPC fails at the same branch). +type midCopyFailReader struct{ served bool } + +func (r *midCopyFailReader) Read(p []byte) (int, error) { + if !r.served { + r.served = true + return copy(p, "plaintext"), nil + } + return 0, errors.New("simulated mid-copy failure") +} + +// A Save that fails after creating the file must not leave a partial file +// behind: the orphan sweep is DB-row-driven and nothing walks the storage +// dir, so a row-less file would be leaked forever. TestSave_ExceedsMaxSize +// locks the same contract for the oversize branch. +func TestSave_MidCopyError_RemovesPartialFile(t *testing.T) { + tmpDir := t.TempDir() + s, err := storage.New(tmpDir, 10) + if err != nil { + t.Fatalf("New: %v", err) + } + + if _, err = s.Save("partial-file", &midCopyFailReader{}); err == nil { + t.Fatal("Save with mid-copy failure should return error") + } + if _, statErr := os.Stat(filepath.Join(tmpDir, "partial-file")); !os.IsNotExist(statErr) { + t.Error("partial file should be removed after a failed save") + } +} + // ─── resolvedPath edge case (via Save with dot prefix) ────────────────────── func TestSave_HiddenFilename(t *testing.T) { diff --git a/Server/updater/container.go b/Server/updater/container.go new file mode 100644 index 00000000..fb3a8170 --- /dev/null +++ b/Server/updater/container.go @@ -0,0 +1,38 @@ +package updater + +import ( + "os" + "strings" +) + +// containerMarkerFiles are runtime-created markers that identify the two +// container engines OwnCord is deployed under in practice. They back up the +// env var for images built before it existed. +var containerMarkerFiles = []string{ + "/.dockerenv", // Docker + "/run/.containerenv", // Podman +} + +// RunningInContainer reports whether the server appears to be running inside +// a container image. OWNCORD_CONTAINER is the authoritative override in both +// directions — the shipped Dockerfile sets it to 1, and an operator who +// bind-mounts the binary into a container and genuinely wants in-place +// self-update can set 0/false to opt back in. Without the variable, the +// engine marker files decide. +// +// In-place self-update is refused in containers because the running binary +// is image content: the replacement written next to it dies with the +// container, and the restart comes back as the old image. Container upgrades +// are image pulls (see docs/deployment.md). +func RunningInContainer() bool { + if v, ok := os.LookupEnv("OWNCORD_CONTAINER"); ok { + v = strings.TrimSpace(v) + return v != "" && v != "0" && !strings.EqualFold(v, "false") + } + for _, marker := range containerMarkerFiles { + if _, err := os.Stat(marker); err == nil { + return true + } + } + return false +} diff --git a/Server/updater/container_test.go b/Server/updater/container_test.go new file mode 100644 index 00000000..0b413e0d --- /dev/null +++ b/Server/updater/container_test.go @@ -0,0 +1,50 @@ +package updater + +import ( + "os" + "testing" +) + +// The env var is authoritative in both directions; the marker-file fallback +// only applies when the variable is absent entirely. +func TestRunningInContainer_EnvSemantics(t *testing.T) { + cases := []struct { + name string + val string + want bool + }{ + {"set to 1", "1", true}, + {"set to true", "true", true}, + {"arbitrary truthy value", "podman", true}, + {"explicit opt-out 0", "0", false}, + {"explicit opt-out false", "false", false}, + {"explicit opt-out FALSE", "FALSE", false}, + {"empty string reads as unset-like opt-out", "", false}, + {"whitespace only", " ", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OWNCORD_CONTAINER", tc.val) + if got := RunningInContainer(); got != tc.want { + t.Errorf("RunningInContainer() with OWNCORD_CONTAINER=%q = %v, want %v", tc.val, got, tc.want) + } + }) + } +} + +// With the variable absent, the answer comes from the engine marker files — +// which do not exist on CI runners or dev machines, so this pins the +// bare-metal default. (The marker-present path is exercised for real in +// every container deployment; the file list is data, not logic.) +func TestRunningInContainer_BareMetalDefault(t *testing.T) { + orig, had := os.LookupEnv("OWNCORD_CONTAINER") + _ = os.Unsetenv("OWNCORD_CONTAINER") + t.Cleanup(func() { + if had { + _ = os.Setenv("OWNCORD_CONTAINER", orig) + } + }) + if RunningInContainer() { + t.Skip("environment reports a container marker — running inside a container, default untestable here") + } +} diff --git a/Server/updater/download.go b/Server/updater/download.go index aee235f7..375c6384 100644 --- a/Server/updater/download.go +++ b/Server/updater/download.go @@ -45,8 +45,14 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download if err := u.ValidateDownloadURL(checksumURL); err != nil { return "", fmt.Errorf("validating checksum URL: %w", err) } - if err := u.ValidateDownloadURL(signatureURL); err != nil { - return "", fmt.Errorf("validating signature URL: %w", err) + // The detached binary signature is consumed only by the Windows verify + // path; Linux integrity comes from the signed manifest + checksum, so a + // release without the .sig asset must not block the Linux path here. + needSignature := runtime.GOOS == "windows" + if needSignature { + if err := u.ValidateDownloadURL(signatureURL); err != nil { + return "", fmt.Errorf("validating signature URL: %w", err) + } } if err := u.ValidateDownloadURL(manifestURL); err != nil { return "", fmt.Errorf("validating manifest URL: %w", err) @@ -59,9 +65,12 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download if err != nil { return "", fmt.Errorf("fetching checksums: %w", err) } - signatureData, err := u.fetchBody(ctx, signatureURL) - if err != nil { - return "", fmt.Errorf("fetching signature: %w", err) + var signatureData []byte + if needSignature { + signatureData, err = u.fetchBody(ctx, signatureURL) + if err != nil { + return "", fmt.Errorf("fetching signature: %w", err) + } } manifestData, err := u.fetchBody(ctx, manifestURL) if err != nil { diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 45d5ffe1..0d8a4f92 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -244,7 +244,7 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { manifestSignatureURL = asset.BrowserDownloadURL } } - requiredAssetsPresent := hasRequiredServerAssets(downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL) + requiredAssetsPresent := hasRequiredServerAssetsFor(runtime.GOOS, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL) updateAvailable = updateAvailable && requiredAssetsPresent return UpdateInfo{ @@ -263,6 +263,14 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { }, nil } -func hasRequiredServerAssets(downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string) bool { - return downloadURL != "" && checksumURL != "" && signatureURL != "" && manifestURL != "" && manifestSignatureURL != "" +// hasRequiredServerAssetsFor reports whether every asset the goos-specific +// verify path consumes is present. The detached binary signature +// (chatserver.exe.sig) is Windows-only: the Linux tarball path verifies via +// the signed manifest + checksum and never receives it, so requiring it there +// would block Linux updates on any release missing a Windows-only asset. +func hasRequiredServerAssetsFor(goos, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string) bool { + if goos == "windows" && signatureURL == "" { + return false + } + return downloadURL != "" && checksumURL != "" && manifestURL != "" && manifestSignatureURL != "" } diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index 48af279a..be636984 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -1300,3 +1300,19 @@ func TestDownloadFile_RefusesPreExistingDest(t *testing.T) { t.Errorf("pre-existing file must be left untouched") } } + +// The chatserver.exe.sig asset is consumed only by the Windows verify path; +// Linux integrity comes entirely from the signed manifest + checksum. Gating +// linux updates on a Windows-only asset makes any release without it report +// UpdateAvailable=false on Linux forever. +func TestHasRequiredServerAssets_SignatureGOOSAware(t *testing.T) { + if !hasRequiredServerAssetsFor("linux", "d", "c", "", "m", "ms") { + t.Error("linux update gated on the windows-only .sig asset") + } + if hasRequiredServerAssetsFor("windows", "d", "c", "", "m", "ms") { + t.Error("windows update must still hard-require the .sig asset") + } + if !hasRequiredServerAssetsFor("windows", "d", "c", "s", "m", "ms") { + t.Error("complete windows asset set refused") + } +} diff --git a/Server/ws/client.go b/Server/ws/client.go index 87c2eb45..01b5d42f 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -28,28 +28,43 @@ type Client struct { ctx context.Context // derived from WS upgrade request; cancelled on disconnect userID int64 user *db.User - channelID int64 // currently viewed channel for channel-scoped broadcasts - voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu - voiceJoinToken string // opaque join-instance token for the current voice session; guarded by voiceMu - e2eePubKey string // ECDH P-256 public key (base64) for voice E2EE; guarded by voiceMu - e2eeSignature string // identity-key signature over e2eePubKey (F3 TOFU); "" for legacy announces; guarded by voiceMu - roleName string // cached role name for chat_message broadcasts - tokenHash string // SHA-256 hex of the session token; used for periodic revalidation - lastSeq uint64 // last_seq sent by the client during auth; 0 = fresh connection (e.g. F5 reload) - connectedAt time.Time // when the WS connection was established - remoteAddr string // client IP:port from the HTTP upgrade request - msgCount int // count of messages processed; resets after session check - msgsReceived int64 // total messages received over the lifetime of this connection - msgsSent int64 // total messages sent over the lifetime of this connection - msgsDropped int64 // messages dropped due to full send buffer - invalidCount int // consecutive invalid messages; reset on valid parse - lastActivity time.Time // last message received from this client; guarded by mu - sendClosed bool // true after all send channels have been closed - send chan []byte // normal-priority outbound messages (chat messages, reactions) - sendHigh chan []byte // high-priority outbound messages (DMs, mentions) - sendLow chan []byte // low-priority outbound messages (typing, presence) — dropped on overflow - mu syncutil.Mutex // guards sendClosed, msgCount, channelID, lastActivity, msgsReceived, msgsSent, msgsDropped - voiceMu syncutil.Mutex // guards voiceChID and voiceJoinToken + channelID int64 // currently viewed channel for channel-scoped broadcasts + voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu + voiceJoinToken string // opaque join-instance token for the current voice session; guarded by voiceMu + e2eePubKey string // ECDH P-256 public key (base64) for voice E2EE; guarded by voiceMu + e2eeSignature string // identity-key signature over e2eePubKey (F3 TOFU); "" for legacy announces; guarded by voiceMu + roleName string // cached role name for chat_message broadcasts + tokenHash string // SHA-256 hex of the session token; used for periodic revalidation + lastSeq uint64 // last_seq sent by the client during auth; 0 = fresh connection (e.g. F5 reload) + // authChannelID is the channel the client says it had open when it + // disconnected, sent alongside last_seq in the auth frame (0 = none). + // + // It exists to close a resume-only hole: registerNow copies the channel + // subscription from the OLD client entry, but when the server already + // observed the previous socket close there is no old entry to copy from, + // so the resumed connection holds NO ChannelTopic subscription until its + // post-auth_ok channel_focus round trip completes. Every channel broadcast + // published in that window reaches nobody on this socket and is + // unrecoverable afterwards, because the client only ever reports max(seq). + // + // UNTRUSTED — it is attacker-controlled like any other auth-frame field. + // handleReconnect promotes it to channelID only after checking it against + // the freshly computed allowed-channel set, and never on a fresh connect. + authChannelID int64 + connectedAt time.Time // when the WS connection was established + remoteAddr string // client IP:port from the HTTP upgrade request + msgCount int // count of messages processed; resets after session check + msgsReceived int64 // total messages received over the lifetime of this connection + msgsSent int64 // total messages sent over the lifetime of this connection + msgsDropped int64 // messages dropped due to full send buffer + invalidCount int // consecutive invalid messages; reset on valid parse + lastActivity time.Time // last message received from this client; guarded by mu + sendClosed bool // true after all send channels have been closed + send chan []byte // normal-priority outbound messages (chat messages, reactions) + sendHigh chan []byte // high-priority outbound messages (DMs, mentions) + sendLow chan []byte // low-priority outbound messages (typing, presence) — dropped on overflow + mu syncutil.Mutex // guards sendClosed, msgCount, channelID, lastActivity, msgsReceived, msgsSent, msgsDropped + voiceMu syncutil.Mutex // guards voiceChID and voiceJoinToken } // wsConn is the subset of github.com/coder/websocket.Conn used by writePump/readPump. @@ -142,6 +157,24 @@ func (c *Client) clearVoiceState() (int64, string) { return oldChID, oldJoinToken } +// clearVoiceStateIfMatch clears the voice state only when the current channel +// is chID, returning the join token and whether it cleared. Delayed evictions +// decided against a snapshotted channel use it so a membership committed after +// the snapshot survives — the in-memory analogue of LeaveVoiceChannelIfMatch. +func (c *Client) clearVoiceStateIfMatch(chID int64) (string, bool) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + if c.voiceChID != chID { + return "", false + } + oldJoinToken := c.voiceJoinToken + c.voiceChID = 0 + c.voiceJoinToken = "" + c.e2eePubKey = "" + c.e2eeSignature = "" + return oldJoinToken, true +} + // setE2EEPubKey stores the ECDH public key for voice E2EE key exchange, // together with its identity-key signature ("" for legacy announces). func (c *Client) setE2EEPubKey(key, signature string) { @@ -253,6 +286,13 @@ func (c *Client) closeSend() { c.closeAllSendLocked() } +// isSendClosed reports whether the client's send channels have been closed. +func (c *Client) isSendClosed() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.sendClosed +} + // closeAllSendLocked closes all three send channels. Caller must hold c.mu. func (c *Client) closeAllSendLocked() { if !c.sendClosed { diff --git a/Server/ws/coverage_voice_lifecycle_test.go b/Server/ws/coverage_voice_lifecycle_test.go index ae06dbc8..5c2158f3 100644 --- a/Server/ws/coverage_voice_lifecycle_test.go +++ b/Server/ws/coverage_voice_lifecycle_test.go @@ -206,6 +206,8 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { } ws.SetVoiceChIDForTest(c1, vcID) ws.SetVoiceChIDForTest(c2, vcID) + hub.SubscribeVoiceTopicForTest(c1, vcID) // as the real voice_join flow does + hub.SubscribeVoiceTopicForTest(c2, vcID) hub.CleanupVoiceForChannel(vcID) @@ -215,6 +217,14 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { if got := ws.GetClientVoiceChIDForTest(c2); got != 0 { t.Errorf("c2 voiceChID = %d, want 0", got) } + // Channel deletion must also drop the voice-topic subscriptions, or the + // clients keep receiving stale voice_e2ee_announce relays for the dead room. + if hub.SubscribedToVoiceTopicForTest(c1, vcID) { + t.Error("c1 still subscribed to the deleted channel's voice topic") + } + if hub.SubscribedToVoiceTopicForTest(c2, vcID) { + t.Error("c2 still subscribed to the deleted channel's voice topic") + } states, _ := database.GetChannelVoiceStates(context.Background(), vcID) if len(states) != 0 { @@ -222,6 +232,39 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { } } +// A user who moved to another voice channel between the cleanup's DB snapshot +// and its per-participant loop must not be clobbered: the deleted channel's +// stale row goes away, but the live client state and the new channel's +// voice-topic subscription are untouched. +func TestCleanupVoiceForChannel_DoesNotClobberMovedParticipant(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cvfc-moved") + oldVC := seedVoiceChannel(t, database, "cvfc-moved-old") + newVC := seedVoiceChannel(t, database, "cvfc-moved-new") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // DB row still on the old channel (the snapshot the cleanup reads), but + // the client has already moved on to the new channel. + if err := database.JoinVoiceChannel(context.Background(), user.ID, oldVC); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + ws.SetVoiceChIDForTest(c, newVC) + hub.SubscribeVoiceTopicForTest(c, newVC) + + hub.CleanupVoiceForChannel(oldVC) + + if got := ws.GetClientVoiceChIDForTest(c); got != newVC { + t.Errorf("moved participant's client voiceChID = %d, want %d", got, newVC) + } + if !hub.SubscribedToVoiceTopicForTest(c, newVC) { + t.Error("moved participant lost the new channel's voice-topic subscription") + } +} + func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) { hub, database := newCoverageHub(t) vcID := seedVoiceChannel(t, database, "cvfc-empty-vc") diff --git a/Server/ws/dm_group_call_test.go b/Server/ws/dm_group_call_test.go index ded6f86e..3c5e1dd1 100644 --- a/Server/ws/dm_group_call_test.go +++ b/Server/ws/dm_group_call_test.go @@ -317,6 +317,91 @@ func TestCallRing_RateLimited(t *testing.T) { } } +func TestCallDecline_RateLimited(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "declinelimit-alice") + bob := seedMemberUser(t, database, "declinelimit-bob") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + sendAlice := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + hub.Register(cAlice) + waitRegistered(t, hub, cAlice) + + // Same shape as call_ring: each frame costs participant + block lookups + // and a fan-out to every other participant, so it needs the same limit. + hub.HandleMessageForTest(cAlice, callMsg("call_decline", chID)) + hub.HandleMessageForTest(cAlice, callMsg("call_decline", chID)) + + if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED on a second immediate decline, got %q", code) + } + _ = bob +} + +// A block must silence the 1:1 ring like every other DM sink: without it a +// blocked user could still make the blocker's client ring (A-2026-08-03). +func TestCallRing_BlockedOneToOneForbidden(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ring-blk-alice") + bob := seedMemberUser(t, database, "ring-blk-bob") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + hub.Register(cAlice) + hub.Register(cBob) + waitRegistered(t, hub, cBob) + + hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID)) + + if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN ringing a blocked 1:1 DM, got %q", code) + } + if got := dmFindMsgType(dmDrainAll(sendBob), "call_incoming"); got != nil { + t.Error("a blocked user's ring reached the blocker") + } +} + +// The group exemption applies to rings exactly as it does to sends: a block +// between two members must not silence the room's call signal for everyone. +func TestCallRing_GroupWithInternalBlockStillRings(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ring-gblk-alice") + bob := seedMemberUser(t, database, "ring-gblk-bob") + carol := seedMemberUser(t, database, "ring-gblk-carol") + chID := seedGroupDM(t, database, "BlockedRingers", alice.ID, bob.ID, carol.ID) + + if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + sendBob := make(chan []byte, 64) + sendCarol := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, make(chan []byte, 64)) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol) + hub.Register(cAlice) + hub.Register(cBob) + hub.Register(cCarol) + waitRegistered(t, hub, cCarol) + + hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID)) + + if dmWaitMsgType(sendBob, "call_incoming", waitTimeout) == nil { + t.Error("a group ring was silenced by a block between two members") + } + if dmWaitMsgType(sendCarol, "call_incoming", waitTimeout) == nil { + t.Error("carol did not receive the group call_incoming") + } +} + func TestCallRing_RejectsNonPositiveChannel(t *testing.T) { hub, database := newHandlerHub(t) alice := seedOwnerUser(t, database, "ringbad-alice") diff --git a/Server/ws/emit.go b/Server/ws/emit.go index db59d858..d0d91972 100644 --- a/Server/ws/emit.go +++ b/Server/ws/emit.go @@ -21,8 +21,9 @@ func (h *Hub) EmitEvents(ctx context.Context, events []Event) { for _, ev := range events { switch e := ev.(type) { case SequencedDMEvent: - // High priority: DMs are time-sensitive. - h.sendSequencedToUsersHigh(e.ChannelID(), e.ParticipantIDs(), e.Payload()) + // Normal priority: sequenced frames must share the per-client FIFO + // so the max-seq ack watermark never passes an undelivered event. + h.sendSequencedToUsers(e.ChannelID(), e.ParticipantIDs(), e.Payload()) case VoiceChannelGuardedEvent: h.sendToUserIfInVoiceChannel(e.VoiceChannelID(), e.TargetUserID(), e.Payload()) case VoiceChannelEvent: @@ -32,6 +33,19 @@ func (h *Hub) EmitEvents(ctx context.Context, events []Event) { h.broadcastExcludeLow(e.ChannelID(), e.ExcludeUserID(), e.Payload()) case UserTargetedEvent: // High priority: targeted events (DM opens, mentions). + // dm_channel_open is unsequenced and targeted, so replay can never + // deliver it — an addressee mid-reconnect would be left with an + // unreachable DM channel. Bump the visibility watermark so any + // client resuming from a seq at or before the open takes the + // full-ready path (whose payload includes DM channels). + if _, isOpen := ev.(DMChannelOpenEvent); isOpen { + // Ratcheted upward only: see bumpVisibilityWatermark on Hub. + // A plain Store(Load(&h.seq)) here (as on the other two + // writers) let a writer that read an older h.seq overwrite a + // concurrently stored higher watermark, silently regressing + // mustFullResync's boundary. + h.bumpVisibilityWatermark() + } h.SendToUserHigh(e.TargetUserID(), e.Payload()) case ChannelEvent: h.BroadcastToChannel(e.ChannelID(), e.Payload()) diff --git a/Server/ws/emit_seq_order_test.go b/Server/ws/emit_seq_order_test.go new file mode 100644 index 00000000..fdf9e66a --- /dev/null +++ b/Server/ws/emit_seq_order_test.go @@ -0,0 +1,55 @@ +package ws + +import ( + "context" + "testing" +) + +// A seq-stamped frame must never ride the high-priority queue: writePump +// drains sendHigh to exhaustion before send, so a sequenced DM would reach +// the socket ahead of lower-seq events still queued in send. The client acks +// max(seq) and replay is strictly seq > last_seq, so a disconnect in that +// window silently and permanently loses the overtaken events. All sequenced +// frames must share the one per-client FIFO. +func TestEmitSequencedDM_UsesNormalQueueToPreserveSeqOrder(t *testing.T) { + h := newEmitTestHub() + send := make(chan []byte, 8) + sendHigh := make(chan []byte, 8) + c := NewTestClientWithChannel(h, 42, 0, send) + c.sendHigh = sendHigh // split queues so the test can see which one delivers + h.clients[42] = c + h.pubsub.Subscribe(c, UserTopic(42)) + + h.EmitEvents(context.Background(), []Event{stubSequencedDMEvent{ + channelID: 7, + participantIDs: []int64{42}, + payload: []byte(`{"type":"test_dm"}`), + }}) + + if got := len(sendHigh); got != 0 { + t.Fatalf("sequenced DM rode the high-priority queue (%d frames); it can overtake lower-seq events", got) + } + if got := len(send); got != 1 { + t.Fatalf("sequenced DM not delivered on the normal FIFO: got %d frames, want 1", got) + } +} + +// Unsequenced targeted events (DM opens, voice tokens) carry no seq, so the +// high-priority fast lane stays correct for them. +func TestEmitUserTargeted_KeepsHighPriorityFastLane(t *testing.T) { + h := newEmitTestHub() + send := make(chan []byte, 8) + sendHigh := make(chan []byte, 8) + c := NewTestClientWithChannel(h, 42, 0, send) + c.sendHigh = sendHigh + h.clients[42] = c + + h.EmitEvents(context.Background(), []Event{stubUserTargetedEvent{ + targetUserID: 42, + payload: []byte(`{"type":"test_targeted"}`), + }}) + + if got := len(sendHigh); got != 1 { + t.Fatalf("unsequenced targeted event should use the high-priority queue: got %d frames, want 1", got) + } +} diff --git a/Server/ws/event.go b/Server/ws/event.go index 8370d7a7..d9372382 100644 --- a/Server/ws/event.go +++ b/Server/ws/event.go @@ -338,7 +338,7 @@ type PluginBroadcastEvent struct { payload []byte } -func (e PluginBroadcastEvent) EventType() string { return "plugin_broadcast" } +func (e PluginBroadcastEvent) EventType() string { return MsgTypePluginBroadcast } func (e PluginBroadcastEvent) ChannelID() int64 { return e.channelID } func (e PluginBroadcastEvent) Payload() []byte { return e.payload } diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go index 6afd6af4..3e313fa6 100644 --- a/Server/ws/event_persister.go +++ b/Server/ws/event_persister.go @@ -42,6 +42,12 @@ type EventPersister struct { stopOnce sync.Once stop chan struct{} done chan struct{} + // stopCtxDone is the Done channel of the context passed to Stop. run's + // drain-on-stop loop reads it only after observing stop closed — the + // close/receive pair provides the happens-before, so there is no data + // race and no lock. A nil value (an uncancellable Stop ctx, e.g. + // context.Background) means "drain fully". + stopCtxDone <-chan struct{} persisted atomic.Uint64 dropped atomic.Uint64 @@ -112,23 +118,36 @@ func (p *EventPersister) Enqueue(seq int64, eventType string, channelID int64, p } } -// Stop signals the persister to drain remaining events and exit. Blocks until -// the goroutine exits or ctx is cancelled. Safe to call without a prior -// Start: in that case there's no goroutine to wait for and Stop returns -// immediately after closing the stop channel. +// Stop signals the persister to drain remaining events and exit, and returns +// only after the run goroutine has fully exited (i.e. has stopped touching +// the store). This is the load-bearing contract: main.go closes the database +// right after Stop returns (LIFO defers), so Stop must guarantee no flush is +// still in flight — otherwise a late flush writes into a closed pool and +// events are lost. ctx does NOT abandon that wait; it only bounds how long +// run() keeps draining the queue before it stops accepting new entries, does +// one final flush, and exits (see run). A single stuck flush therefore +// delays shutdown by at most that flush rather than closing the DB +// underneath it. +// +// Safe to call without a prior Start: in that case there's no goroutine to +// wait for and Stop returns immediately after closing the stop channel. func (p *EventPersister) Stop(ctx context.Context) { if p == nil { return } - p.stopOnce.Do(func() { close(p.stop) }) + p.stopOnce.Do(func() { + // Published before close(p.stop): run() reads stopCtxDone only after + // its receive on p.stop observes the close, and the close/receive + // pair makes this write visible without a data race. + p.stopCtxDone = ctx.Done() + close(p.stop) + }) if !p.started.Load() { // run() was never launched, so done will never be closed. return } - select { - case <-p.done: - case <-ctx.Done(): - } + // Always wait for the goroutine to exit — never race it against ctx. + <-p.done } // Stats returns lifetime counters. @@ -181,7 +200,13 @@ func (p *EventPersister) run(ctx context.Context) { for { select { case <-p.stop: - // Drain anything still in the channel before exiting. + // Drain anything still in the channel before exiting. The drain + // is bounded by the Stop context (p.stopCtxDone): once it fires + // we do one final flush and exit rather than keep pulling, so a + // slow store delays shutdown by at most one flush instead of + // unboundedly. Either way the goroutine finishes any in-flight + // flush before returning (and closing p.done), so Stop's caller + // never closes the store under a live flusher. for { select { case evt := <-p.queue: @@ -189,6 +214,9 @@ func (p *EventPersister) run(ctx context.Context) { if len(batch) >= p.batchSize { flush() } + case <-p.stopCtxDone: + flush() + return default: flush() return diff --git a/Server/ws/event_persister_test.go b/Server/ws/event_persister_test.go index d70807ef..381dd0b1 100644 --- a/Server/ws/event_persister_test.go +++ b/Server/ws/event_persister_test.go @@ -90,6 +90,56 @@ func TestEventPersisterDropsOnFullQueue(t *testing.T) { } } +// slowEventStore wraps a real EventStore and adds an artificial delay to +// PersistEvents, so tests can make an in-flight flush deterministically +// outlast a short Stop context. +type slowEventStore struct { + EventStore + delay time.Duration +} + +func (s *slowEventStore) PersistEvents(ctx context.Context, events []db.PersistedEvent) (int, error) { + time.Sleep(s.delay) + return s.EventStore.PersistEvents(ctx, events) +} + +// TestEventPersisterStopWaitsForGoroutineExit pins the fixed contract: Stop +// must not return until the run goroutine has finished its in-flight flush, +// even when the Stop context expires first. The store flush (200ms) far +// outlasts the Stop ctx (20ms); the old select{done|ctx.Done} would have +// returned at ~20ms with nothing persisted, letting main.go's LIFO +// database.Close() run underneath a still-flushing goroutine. The fix must +// return only after the flush completes, with every event persisted. +func TestEventPersisterStopWaitsForGoroutineExit(t *testing.T) { + mem := openPersisterTestDB(t) + store := &slowEventStore{EventStore: mem, delay: 200 * time.Millisecond} + // Neither the batch (1024) nor the ticker (1h) can flush before Stop is + // called; only Stop's drain flushes, so the in-flight flush is + // deterministic. + p := NewEventPersister(store, 64, 1024, time.Hour) + p.Start(context.Background()) + for i := range 5 { + p.Enqueue(int64(i+1), "broadcast", 0, []byte(`{}`)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + start := time.Now() + p.Stop(ctx) + elapsed := time.Since(start) + + if elapsed < 150*time.Millisecond { + t.Errorf("Stop returned after %v, want it to block for the ~200ms flush "+ + "(it must not abandon the goroutine when ctx expires — main.go closes "+ + "the DB right after Stop returns)", elapsed) + } + persisted, _, _, _ := p.Stats() + if persisted != 5 { + t.Errorf("persisted=%d, want 5 (Stop must wait for the in-flight flush to finish)", persisted) + } +} + func TestEventPersisterStopDrains(t *testing.T) { mem := openPersisterTestDB(t) p := NewEventPersister(mem, 256, 100, time.Hour) diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 1f13596d..36b91f39 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -66,6 +66,20 @@ func SetClientVoiceChID(c *Client, channelID int64) { SetVoiceChIDForTest(c, channelID) } +// SubscribedToVoiceTopicForTest reports whether c itself (identity compare, +// not just its userID) holds the subscription to channelID's voice topic. +func (h *Hub) SubscribedToVoiceTopicForTest(c *Client, channelID int64) bool { + h.pubsub.mu.RLock() + defer h.pubsub.mu.RUnlock() + return h.pubsub.topics[VoiceTopic(channelID)][c.userID] == c +} + +// SubscribeVoiceTopicForTest subscribes c to channelID's voice topic, as the +// production voice_join flow does. +func (h *Hub) SubscribeVoiceTopicForTest(c *Client, channelID int64) { + h.pubsub.Subscribe(c, VoiceTopic(channelID)) +} + // SetClientVoiceStateForTest sets both the voice channel and join token. func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { c.voiceMu.Lock() @@ -386,3 +400,7 @@ func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { func (h *Hub) BroadcastVoiceEventForTest(channelID int64, msg []byte) { h.broadcastVoiceEvent(context.Background(), channelID, msg) } + +// MaxColdReplayForTest exposes the cold-tier replay row cap so tests can seed +// exactly enough events to hit it. +const MaxColdReplayForTest = maxColdReplay diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go index 136ab048..178fc446 100644 --- a/Server/ws/handler_v2_channel_focus_test.go +++ b/Server/ws/handler_v2_channel_focus_test.go @@ -131,6 +131,109 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) { } } +func TestChannelFocusV2_RateLimited_SilentDrop(t *testing.T) { + deps, userID, chID := newFocusTestDeps(t) + deps.Limiter = auth.NewRateLimiter() + cmd := ChannelFocusCmd{userID: userID, channelID: chID} + info := ClientInfo{UserID: userID, Username: "focuser"} + + // Every frame drives an unmetered SQLite write; 5/s must be enough for + // any legitimate focus churn, and the 6th within the window is dropped. + for i := range 5 { + res := handleChannelFocusV2(context.Background(), cmd, info, deps) + if res.SetChannelID == nil { + t.Fatalf("in-budget focus %d must set the channel id", i) + } + } + res := handleChannelFocusV2(context.Background(), cmd, info, deps) + if res.SetChannelID != nil { + t.Error("rate-limited channel_focus must be dropped (no SetChannelID)") + } + if res.Error != nil { + t.Errorf("silent drop expected, got error %v", res.Error) + } +} + +func TestMarkReadV2_RateLimited_SkipsReadStateWrite(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + t.Cleanup(func() { database.Close() }) + + userID, _ := database.CreateUser(context.Background(), "marklimit", "hash", 1) + chID, _ := database.CreateChannel(context.Background(), "mark-chan", "text", "", "", 0) + svc := service.New(database, auth.NewRateLimiter()) + deps := PresenceDeps{Limiter: auth.NewRateLimiter(), ChannelSvc: svc.Channels} + info := ClientInfo{UserID: userID, Username: "marklimit"} + + insertMsg := func(content string) int64 { + t.Helper() + res, execErr := database.ExecContext(context.Background(), + `INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)`, + chID, userID, content) + if execErr != nil { + t.Fatalf("insert message: %v", execErr) + } + id, _ := res.LastInsertId() + return id + } + readStateID := func() int64 { + t.Helper() + var id int64 + if scanErr := database.QueryRowContext(context.Background(), + `SELECT last_message_id FROM read_states WHERE user_id = ? AND channel_id = ?`, + userID, chID).Scan(&id); scanErr != nil { + t.Fatalf("read read_states: %v", scanErr) + } + return id + } + + m1 := insertMsg("first") + // Exhaust the shared focus/mark_read budget (5/s). + for range 5 { + handleMarkReadV2(context.Background(), MarkReadCmd{userID: userID, channelID: chID}, info, deps) + } + if got := readStateID(); got != m1 { + t.Fatalf("read state after in-budget mark_read = %d, want %d", got, m1) + } + + insertMsg("second") + // The 6th frame in the window must not reach the SQLite writer. + handleMarkReadV2(context.Background(), MarkReadCmd{userID: userID, channelID: chID}, info, deps) + if got := readStateID(); got != m1 { + t.Errorf("rate-limited mark_read advanced read state to %d, want it held at %d", got, m1) + } +} + +func TestMarkReadV2Burst_DoesNotStarveChannelFocus(t *testing.T) { + deps, userID, chID := newFocusTestDeps(t) + deps.Limiter = auth.NewRateLimiter() + info := ClientInfo{UserID: userID, Username: "focuser"} + + // A "Mark All as Read" burst exhausts mark_read's own 5/s budget... + for range 5 { + res := handleMarkReadV2(context.Background(), MarkReadCmd{userID: userID, channelID: chID}, info, deps) + if res.Error != nil { + t.Fatalf("in-budget mark_read %v returned error", res.Error) + } + } + markRes := handleMarkReadV2(context.Background(), MarkReadCmd{userID: userID, channelID: chID}, info, deps) + if markRes.Error != nil { + t.Fatalf("rate-limited mark_read returned error %v, want silent drop", markRes.Error) + } + + // ...but must not consume any of channel_focus's separate budget: a + // channel switch immediately after the burst still succeeds. + focusRes := handleChannelFocusV2(context.Background(), ChannelFocusCmd{userID: userID, channelID: chID}, info, deps) + if focusRes.SetChannelID == nil { + t.Fatal("channel_focus after a mark_read burst must still set the channel id, not be starved by a shared budget") + } +} + func TestChannelFocusV2_NoEvents(t *testing.T) { deps, userID, chID := newFocusTestDeps(t) cmd := ChannelFocusCmd{userID: userID, channelID: chID} diff --git a/Server/ws/handlers_call.go b/Server/ws/handlers_call.go index bf5464ee..a9c8fecc 100644 --- a/Server/ws/handlers_call.go +++ b/Server/ws/handlers_call.go @@ -70,6 +70,12 @@ func handleCallDeclineV2(ctx context.Context, cmd Command, info ClientInfo, deps d := deps.(CallDeps) declineCmd := cmd.(CallDeclineCmd) + // Same cost shape as call_ring (participant + block lookups, fan-out to + // every other participant), so it carries the same limit. + if d.Limiter != nil && !d.Limiter.Allow(auth.Key("call_decline", info.UserID), callRingRateLimit, callRingWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many call actions"}} + } + targets, err := d.DMSvc.RingTargets(ctx, info.UserID, declineCmd.ChannelID()) if err != nil { return serviceErrorToResult(err) diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index aff262fa..b8754c15 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -19,8 +19,6 @@ import ( "github.com/owncord/server/service" ) -const MsgTypeChatCommand = "chat_command" - // maxCommandArgs is the maximum number of arguments accepted in a // chat_command payload. This prevents a malicious client from flooding // the plugin's allocate/dispatch ABI with thousands of strings. @@ -112,7 +110,7 @@ func buildCommandReply(reqID, text string) []byte { Payload payload `json:"payload"` } raw, _ := json.Marshal(envelope{ - Type: "command_reply", + Type: MsgTypeCommandReply, ReqID: reqID, Payload: payload{Text: text}, }) @@ -132,7 +130,7 @@ func buildCommandBroadcast(channelID, userID int64, cmd, text string) []byte { Payload payload `json:"payload"` } raw, _ := json.Marshal(envelope{ - Type: "plugin_broadcast", + Type: MsgTypePluginBroadcast, Payload: payload{ ChannelID: channelID, UserID: userID, diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index 4b41b026..e3a42f50 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -3,10 +3,24 @@ package ws import ( "context" "errors" + "time" + "github.com/owncord/server/auth" "github.com/owncord/server/service" ) +// channel_focus and mark_read each get their own 5/s budget under this same +// limit/window even though both run the identical HandleChannelFocus service +// call. They used to share one auth.Key("focus", ...) budget, which let a +// "Mark All as Read" burst (one mark_read per badged channel) exhaust the +// window and silently drop the next legitimate channel_focus — leaving the +// connection subscribed to the previous channel's pub/sub topic with no +// error surfaced to the client. +const ( + focusRateLimit = 5 + focusRateWindow = time.Second +) + // registerPresenceHandlers registers presence, typing, and channel focus handlers. // All three are V2 handlers. func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { @@ -85,6 +99,15 @@ func handleChannelFocusV2(ctx context.Context, cmd Command, info ClientInfo, dep focusCmd := cmd.(ChannelFocusCmd) chID := focusCmd.ChannelID() + // Every frame drives an unmetered SQLite write (UpdateReadState) plus + // perm checks and pubsub churn, so focus is metered on its own key — + // mark_read runs the identical service call but must not be able to + // spend this budget (see handleMarkReadV2). Silently dropping matches + // the handlers' existing error posture. + if d.Limiter != nil && !d.Limiter.Allow(auth.Key("focus", info.UserID), focusRateLimit, focusRateWindow) { + return Result{} + } + _, err := d.ChannelSvc.HandleChannelFocus(ctx, info.UserID, chID) if err != nil { if errors.Is(err, service.ErrForbidden) { @@ -105,6 +128,14 @@ func handleMarkReadV2(ctx context.Context, cmd Command, info ClientInfo, deps an d := deps.(PresenceDeps) markCmd := cmd.(MarkReadCmd) + // Own budget, separate from channel_focus: a "Mark All as Read" burst + // (one mark_read per badged channel) must not starve a legitimate + // channel_focus that shares the same 5/s window — that silently leaves + // the connection subscribed to the old channel's pub/sub topic. + if d.Limiter != nil && !d.Limiter.Allow(auth.Key("markread", info.UserID), focusRateLimit, focusRateWindow) { + return Result{} + } + _, err := d.ChannelSvc.HandleChannelFocus(ctx, info.UserID, markCmd.ChannelID()) if err != nil { if errors.Is(err, service.ErrForbidden) { diff --git a/Server/ws/harvest_s4_internal_test.go b/Server/ws/harvest_s4_internal_test.go new file mode 100644 index 00000000..c72e5d53 --- /dev/null +++ b/Server/ws/harvest_s4_internal_test.go @@ -0,0 +1,275 @@ +package ws + +// Internal tests for the 2026-08-06 harvest ws findings: kickClient ordering, +// topic-limiter seq consumption, limiter bucket pruning, dm_channel_open +// resync watermark, DM-lookup error propagation, writePump drain, and the +// failed-handshake presence teardown. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// A Subscribe racing kickClient (the channel_focus applier runs on the +// readPump goroutine concurrently with sweep/DisconnectUser kicks) must never +// leave the dead client holding the topic: nothing cleans it up afterwards +// and the user silently loses that channel's stream. registerNow documents +// the required ordering — closeSend BEFORE UnsubscribeAll — because +// Subscribe's only re-take guard is isSendClosed. +func TestKickClient_SubscribeRaceCannotLeaveDeadSubscriber(t *testing.T) { + for range 300 { + h := newEmitTestHub() + c := NewTestClient(h, 1, make(chan []byte, 8)) + h.clients[1] = c + h.pubsub.Subscribe(c, ChannelTopic(3)) + + done := make(chan struct{}) + go func() { + h.pubsub.Subscribe(c, ChannelTopic(3)) + close(done) + }() + h.kickClient(c) + <-done + + h.pubsub.mu.RLock() + sub := h.pubsub.topics[ChannelTopic(3)][1] + h.pubsub.mu.RUnlock() + if sub != nil { + t.Fatal("a Subscribe racing kickClient left a dead client holding the topic — its user silently loses the channel stream") + } + } +} + +// A frame shed by the topic rate limiter must not have consumed a sequence +// number: the client tracks only max(seq), so a seq that was buffered but +// never published is permanently invisible to replay — the codebase states +// this invariant twice (sendSequencedToUsers, the maxColdReplay cap comment). +func TestDeliverBroadcast_ShedFrameLeavesNoSeqGap(t *testing.T) { + h := newEmitTestHub() + send := make(chan []byte, 4096) + c := NewTestClient(h, 1, send) + h.clients[1] = c + h.pubsub.Subscribe(c, ChannelTopic(5)) + + total := topicRateLimitPerSecond + 20 + for range total { + h.deliverBroadcast(broadcastMsg{channelID: 5, msg: []byte(`{"type":"chat_message"}`)}) + } + + deliveredSeqs := make(map[uint64]bool) +readLoop: + for { + select { + case msg := <-send: + var frame struct { + Seq uint64 `json:"seq"` + } + if err := json.Unmarshal(msg, &frame); err == nil { + deliveredSeqs[frame.Seq] = true + } + default: + break readLoop + } + } + + // Walk the ring buffer directly (single-threaded here): every seq it + // holds must also have been delivered live. + for _, e := range h.replayBuf.entries { + if e.seq == 0 { + continue + } + if !deliveredSeqs[e.seq] { + t.Fatalf("seq %d sits in the replay buffer but was never published live — a shed frame consumed it, and no client can ever request it back", e.seq) + } + } + if len(deliveredSeqs) == 0 { + t.Fatal("test broken: nothing was delivered at all") + } +} + +// The per-channel token buckets are created on first broadcast and were never +// pruned in production — Cleanup existed but had no caller. The stale-client +// tick is the natural place. +func TestStaleTick_PrunesIdleTopicLimiterBuckets(t *testing.T) { + h := newEmitTestHub() + h.topicLimiter.Allow(ChannelTopic(9)) + + h.topicLimiter.mu.Lock() + h.topicLimiter.buckets[ChannelTopic(9)].lastReset = time.Now().Add(-time.Hour) + h.topicLimiter.mu.Unlock() + + h.onStaleTick() + + h.topicLimiter.mu.Lock() + _, exists := h.topicLimiter.buckets[ChannelTopic(9)] + h.topicLimiter.mu.Unlock() + if exists { + t.Error("idle topic bucket survived the stale tick — the bucket map grows for the process lifetime") + } +} + +// dm_channel_open is unsequenced and targeted: an addressee mid-reconnect +// never receives it, while the DM's sequenced chat_message replays fine — +// leaving an unreachable channel until the next full ready. Emitting one must +// bump the visibility watermark so any client resuming from a seq at or +// before the open takes the full-ready path (whose payload includes DMs). +func TestEmitEvents_DMChannelOpenForcesFullResyncForOlderClients(t *testing.T) { + h := newEmitTestHub() + atomic.StoreUint64(&h.seq, 40) + + h.EmitEvents(context.Background(), []Event{ + DMChannelOpenEvent{targetUserID: 7, payload: []byte(`{"type":"dm_channel_open"}`)}, + }) + + if !h.mustFullResync(40) { + t.Error("a client resuming from a seq at or before a dm_channel_open must be forced onto the full-ready path") + } + if h.mustFullResync(41) { + t.Error("clients past the open must keep replaying normally") + } +} + +// computeAllowedChannels treated a DM-channel lookup failure as non-fatal, +// silently stripping every DM event from the replay while the client's +// lastSeq advances past them — a permanent hole. Its three sibling lookups +// in the same function are fatal, and handleReconnect's error path already +// falls back to the safe full-ready resync. +func TestComputeAllowedChannels_DMLookupErrorIsFatal(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + t.Cleanup(func() { database.Close() }) + + userID, _ := database.CreateUser(context.Background(), "dm-lookup-err", "hash", 1) + user, _ := database.GetUserByID(context.Background(), userID) + h := NewHub(database, auth.NewRateLimiter(), nil) + + // Fault-inject exactly the DM lookup; the earlier role/channel lookups + // keep working. + if _, err := database.ExecContext(context.Background(), `DROP TABLE dm_open_state`); err != nil { + t.Fatalf("drop dm_open_state: %v", err) + } + + if _, err := h.computeAllowedChannels(context.Background(), database, user); err == nil { + t.Error("a failed DM-channel lookup must be an error (forcing full ready), not a silently DM-stripped replay") + } +} + +// The kick paths queue their reason frame (e.g. the BANNED error that makes +// the client clear its credentials) on c.send and then close all send +// channels, relying on writePump to drain remaining messages — serve.go and +// hub_broadcast.go both document that contract. A pump that returns the +// moment it sees a closed channel drops the frame. +func TestWritePump_DrainsQueuedFramesAfterCloseSend(t *testing.T) { + frame := []byte(`{"type":"error","payload":{"code":"BANNED"}}`) + c := &Client{ + userID: 1, + send: make(chan []byte, 8), + sendHigh: make(chan []byte, 8), + sendLow: make(chan []byte, 8), + } + c.send <- frame + c.closeSend() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, acceptErr := websocket.Accept(w, r, nil) + if acceptErr != nil { + return + } + writePump(r.Context(), conn, c) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + _, msg, err := conn.Read(ctx) + if err != nil { + t.Fatalf("kick frame was dropped instead of drained: %v", err) + } + if !bytes.Contains(msg, []byte("BANNED")) { + t.Fatalf("unexpected frame before close: %s", msg) + } +} + +// When a post-registerNow handshake write fails, no readPump ever starts for +// the new connection, and the replaced old connection's defer already ran +// (skipping teardown because the new client held the slot) — so nobody marks +// the user offline. The failure path must run the standard disconnect +// teardown itself whenever unregisterNow reports no replacement holds the +// slot. +func TestFailedHandshake_MarksUserOfflineWhenNoReplacementRemains(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + t.Cleanup(func() { database.Close() }) + + userID, _ := database.CreateUser(context.Background(), "handshake-fail", "hash", 1) + if err := database.UpdateUserStatus(context.Background(), userID, "online"); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + h := NewHub(database, auth.NewRateLimiter(), nil) + + // Old connection A holds the slot; B replaces it (registerNow kicks A); + // A's readPump defer runs while B holds the slot → teardown skipped. + oldClient := NewTestClient(h, userID, make(chan []byte, 8)) + oldClient.user = &db.User{ID: userID, Status: "online"} + h.clients[userID] = oldClient + + newClient := NewTestClient(h, userID, make(chan []byte, 8)) + newClient.user = &db.User{ID: userID, Status: "online"} + newClient.lastSeq = 1 + h.registerNow(newClient, nil) + if replaced := h.unregisterNow(oldClient); !replaced { + t.Fatal("precondition: old client's defer must see itself replaced") + } + + // B's auth_ok/ready write fails; the handshake failure path runs. + h.unregisterFailedHandshake(context.Background(), newClient) + + user, err := database.GetUserByID(context.Background(), userID) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.Status == "online" { + t.Error("user stuck online after a failed handshake with no surviving connection") + } + + // The other clients must hear about it too. + select { + case bm := <-h.broadcast: + if !bytes.Contains(bm.msg, []byte("presence")) { + t.Errorf("expected a presence broadcast, got %s", bm.msg) + } + default: + t.Error("no presence broadcast queued after the failed handshake teardown") + } +} diff --git a/Server/ws/harvest_s5_internal_test.go b/Server/ws/harvest_s5_internal_test.go new file mode 100644 index 00000000..e7ac9052 --- /dev/null +++ b/Server/ws/harvest_s5_internal_test.go @@ -0,0 +1,241 @@ +package ws + +// Internal tests for the 2026-08-06 harvest S5 ws findings: channel-scoped +// voice eviction in the revocation sweep, the aborted-switch voice-topic +// restore, voice_camera failing closed on a channel-lookup error, and the +// IsRunning/Start data race in the LiveKit process manager. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// harvestVoiceRoleID is a non-seeded role carrying the voice bits these tests +// exercise, so they do not depend on what the migrations grant the defaults. +const harvestVoiceRoleID = int64(200) + +func newHarvestVoiceDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (?, 'harvest-voice', NULL, ?, 5, 0)`, + harvestVoiceRoleID, + permissions.ReadMessages|permissions.ConnectVoice|permissions.UseVideo, + ); err != nil { + t.Fatalf("seed harvest-voice role: %v", err) + } + return database +} + +func seedHarvestVoiceUser(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + uid, err := database.CreateUser(context.Background(), username, "hash", int(harvestVoiceRoleID)) + if err != nil { + t.Fatalf("CreateUser %s: %v", username, err) + } + return uid +} + +func mustCreateVoiceChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + chID, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel %s: %v", name, err) + } + return chID +} + +// The revocation sweep snapshots a channel id, runs a DB-backed CONNECT_VOICE +// check on it, and then evicts. A voice_join to a PERMITTED channel B landing +// during that DB round-trip must not be torn down: the eviction has to be +// conditional on the client still being in the channel that was checked — the +// same rule CleanupVoiceForChannel and LeaveVoiceChannelIfMatch already apply. +func TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel(t *testing.T) { + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "sweep-race") + chA := mustCreateVoiceChannel(t, database, "voice-a") + chB := mustCreateVoiceChannel(t, database, "voice-b") + // CONNECT_VOICE revoked on A only; B stays permitted. + if err := database.UpsertChannelOverride(context.Background(), chA, harvestVoiceRoleID, 0, permissions.ConnectVoice); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + c := NewTestClient(h, uid, make(chan []byte, 2048)) + h.clients[uid] = c + + for i := range 300 { + c.setVoiceState(chA, "tok-a") + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + // Staggered start so, across iterations, the move lands at + // varying points inside the sweep's permission-check window. + for range i % 50 { + runtime.Gosched() + } + c.setVoiceState(chB, "tok-b") + }() + go func() { + defer wg.Done() + h.sweepStaleVoiceStates() + }() + wg.Wait() + if c.getVoiceChID() == 0 { + t.Fatalf("iteration %d: the sweep evicted the client from channel %d, but the failed CONNECT_VOICE check was for channel %d", i, chB, chA) + } + } +} + +// When a voice channel switch aborts because the old row's delete failed, +// the abort branch restores the in-memory voice state — and must restore the +// voice-topic subscription and key-holder entry torn down with it, or the +// client silently misses every voice_e2ee relay for the session it is still in. +func TestHandleVoiceJoin_AbortedSwitchRestoresVoiceTopicSubscription(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "abort-switch") + chA := mustCreateVoiceChannel(t, database, "voice-a") + chB := mustCreateVoiceChannel(t, database, "voice-b") + + if err := database.JoinVoiceChannel(ctx, uid, chA); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + vs, err := database.GetVoiceState(ctx, uid) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + t.Cleanup(h.Stop) // ends the background leave retries the blocked delete spawns + lk, err := NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "harvest-key", + LiveKitAPISecret: "harvest-secret-0123456789abcdef", + LiveKitURL: "ws://127.0.0.1:9", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + h.livekit = lk + + user, err := database.GetUserByID(ctx, uid) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + c := NewTestClient(h, uid, make(chan []byte, 64)) + c.user = user + h.clients[uid] = c + c.setVoiceState(chA, vs.JoinedAt) + h.pubsub.Subscribe(c, VoiceTopic(chA)) + h.updateKeyHolder(chA) + + // Fault-inject exactly the leave's DELETE: the row survives, GetVoiceState + // still finds it, and the switch takes the abort branch. + if _, err := database.ExecContext(ctx, + `CREATE TRIGGER harvest_block_voice_delete BEFORE DELETE ON voice_states + BEGIN SELECT RAISE(ABORT, 'blocked by test'); END`); err != nil { + t.Fatalf("create trigger: %v", err) + } + + h.handleVoiceJoin(ctx, c, json.RawMessage(fmt.Sprintf(`{"channel_id": %d}`, chB))) + + if got := c.getVoiceChID(); got != chA { + t.Fatalf("aborted switch left client voice state at %d, want restored channel %d", got, chA) + } + if !h.SubscribedToVoiceTopicForTest(c, chA) { + t.Error("aborted switch did not re-subscribe the client to its channel's voice topic — every voice_e2ee relay for the restored session is silently dropped") + } + if !h.IsVoiceKeyHolder(chA, uid) { + t.Error("aborted switch left the key-holder map without the channel's only participant") + } +} + +// voice_camera's VoiceMaxVideo gate must fail closed: a channel-lookup error +// is not "no cap configured", and falling through to the unconditional enable +// bypasses the per-channel video limit. +func TestHandleVoiceCameraV2_ChannelLookupErrorFailsClosed(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "camera-err") + chID := mustCreateVoiceChannel(t, database, "video-room") + if err := database.JoinVoiceChannel(ctx, uid, chID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + // Make exactly GetChannel fail; permission and voice-state queries keep + // working (the role check reads roles/channel_overrides only). + if _, err := database.ExecContext(ctx, `ALTER TABLE channels RENAME TO channels_offline`); err != nil { + t.Fatalf("rename channels: %v", err) + } + + d := VoiceDeps{DB: database, Permissions: permissions.NewChecker(database)} + res := handleVoiceCameraV2(ctx, VoiceCameraCmd{userID: uid, enabled: true}, ClientInfo{UserID: uid, VoiceChannelID: chID}, d) + + if res.Error == nil { + t.Error("voice_camera returned no error when the VoiceMaxVideo lookup failed — the cap check was silently skipped") + } + vs, err := database.GetVoiceState(ctx, uid) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState: %v", err) + } + if vs.Camera { + t.Error("camera was enabled despite the failed VoiceMaxVideo lookup — the handler must fail closed") + } +} + +// RED requires the race detector: IsRunning and Stop read exec.Cmd.Process +// under p.mu, so runLoop must publish that write inside the same critical +// section (Start under p.mu, Wait outside) or the accesses race. +func TestLiveKitProcess_IsRunningWhileStarting_NoRaceOnCmdProcess(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "fake-livekit.sh") + content := "#!/bin/sh\nsleep 1\n" + if runtime.GOOS == "windows" { + script = filepath.Join(dir, "fake-livekit.bat") + content = "@ping -n 2 127.0.0.1 > nul\r\n" + } + if err := os.WriteFile(script, []byte(content), 0o700); err != nil { + t.Fatalf("write fake binary: %v", err) + } + + p := NewLiveKitProcess(&config.VoiceConfig{ + LiveKitAPIKey: "harvest-key", + LiveKitAPISecret: "harvest-secret", + LiveKitURL: "ws://127.0.0.1:9", + }, &config.TLSConfig{}, dir) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + p.runLoop(ctx, filepath.Join(dir, "livekit.yaml"), script) + }() + + deadline := time.Now().Add(300 * time.Millisecond) + for time.Now().Before(deadline) { + p.IsRunning() + } + cancel() + <-done +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 144bbe5d..faee0a78 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -268,7 +268,7 @@ func (h *Hub) Run() { case bm := <-h.broadcast: h.deliverBroadcast(bm) case <-staleTicker.C: - h.sweepStaleClients() + h.onStaleTick() case <-sessionSweepTicker.C: // The revoked-session and stale-voice sweeps do per-client // DB work, so they run off the dispatch goroutine — a slow @@ -325,6 +325,27 @@ func (h *Hub) GracefulStop() { }) } +// bumpVisibilityWatermark ratchets visibilityChangeSeq up to the current seq, +// never down. All three writers (RefreshChannelVisibility, +// revokeUnreadableChannels, DMChannelOpenEvent in emit.go) must go through +// this instead of a plain Store: a plain Store(Load(&h.seq)) lets a writer +// that read an older h.seq — e.g. one that spent time in a per-topic DB loop +// — finish and overwrite a concurrently stored higher watermark with its +// stale value, silently regressing the forced-full-resync boundary mustFullResync +// depends on being monotonic. Mirrors SeedSeq's CAS-max pattern. +func (h *Hub) bumpVisibilityWatermark() { + for { + cur := h.visibilityChangeSeq.Load() + next := atomic.LoadUint64(&h.seq) + if next <= cur { + return + } + if h.visibilityChangeSeq.CompareAndSwap(cur, next) { + return + } + } +} + // IsUserConnected returns true if a client with the given userID is already // registered in the hub. Safe to call from any goroutine. func (h *Hub) IsUserConnected(userID int64) bool { @@ -364,15 +385,50 @@ type clientEvent struct { // readableChannelIDs is the set of channels the user holds READ_MESSAGES on, // as computed by the handshake (serve.go). It gates the inherited voice-channel // subscription only; a nil set denies it (fail closed). +// +// Replacing an existing connection strips its subscriptions (UnsubscribeAll) +// and re-subscribes the new one (Subscribe) as two separate PubSub-lock +// acquisitions — back to back, but not atomic. A caller that must not lose a +// broadcast concurrently racing the replacement (i.e. one deliverBroadcast +// could deliver in the gap between those two acquisitions) has to call this +// while holding h.seqMu, the same lock deliverBroadcast holds for its entire +// critical section (seq allocation, replay-buffer push, and publish) — that +// serializes the two entirely, rather than merely narrowing the window. See +// serve.go's handleReconnect, which re-reads the replay tail and calls +// registerNow inside one h.seqMu section for exactly this reason. func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) { + // Voice channel the replaced connection was in, if any. Re-elected below, + // after the hub lock is released. + var replacedVoiceChID int64 + h.mu.Lock() if old, exists := h.clients[c.userID]; exists { + oldE2EEKey, oldE2EESig := old.getE2EEPubKey() oldVoiceChID, oldVoiceJoinToken := old.clearVoiceState() + replacedVoiceChID = oldVoiceChID if c.lastSeq > 0 { // Network reconnect — preserve voice state so the user stays // in voice during brief WS drops. if c.getVoiceChID() == 0 { c.setVoiceState(oldVoiceChID, oldVoiceJoinToken) + // The announced ECDH key must survive with the voice state: + // the client keeps its keypair across a WS blip and only + // re-announces on a LiveKit-room reconnect, so without the + // transfer voice_join replays nothing for this user and new + // joiners' key exchanges time out. + c.setE2EEPubKey(oldE2EEKey, oldE2EESig) + } + // The focused channel must transfer too: the client never + // re-sends channel_focus on a resume (mountChannel early-returns + // on the same channel), so without it the ChannelTopic + // re-subscribe below is a no-op and the message stream dies + // silently. READ-gated like every ChannelTopic subscription; + // a nil set denies (fail closed). + if oldChID := old.getChannelID(); oldChID != 0 && + c.getChannelID() == 0 && readableChannelIDs[oldChID] { + c.mu.Lock() + c.channelID = oldChID + c.mu.Unlock() } } // Fresh connections (lastSeq == 0): do NOT transfer voice state. @@ -380,20 +436,43 @@ func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) { // by the handshake path in serve.go, which runs before registerNow. // registerNow only handles in-memory client replacement. - // Remove the old client from all pub/sub topics before replacing. - h.pubsub.UnsubscribeAll(old) - // Kick the stale connection atomically before registering // the new one — prevents TOCTOU races on duplicate login. + // closeSend MUST precede UnsubscribeAll: Subscribe refuses clients + // whose send is closed, so this ordering leaves the old connection's + // in-flight handlers no window to re-take a stripped topic. slog.Warn("hub: kicking stale connection for re-registering user", "user_id", c.userID, "last_seq", c.lastSeq) old.closeSend() + + // Remove the old client from all pub/sub topics before replacing. + h.pubsub.UnsubscribeAll(old) } h.clients[c.userID] = c - slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients)) - h.mu.Unlock() - // Subscribe the new client to default pub/sub topics. + // Subscribe the new client to its default pub/sub topics immediately + // after UnsubscribeAll(old) above, with nothing in between. + // + // This does NOT make strip+resubscribe atomic, and must not be read as + // doing so: the two are separate ps.mu acquisitions, and PublishGlobal + // takes ps.mu alone (never h.mu), so a deliverBroadcast landing between + // them still finds no subscriber for this user. That frame is + // unrecoverable — its seq was already allocated and pushed to the replay + // buffer, the resuming client's replay snapshot was taken even earlier, + // and the client tracks only max(seq), so the next frame silently + // advances past the hole. Only a caller holding h.seqMu closes that + // window; see this function's doc comment and serve.go's handleReconnect. + // + // What the ordering does buy is the smallest possible gap for the callers + // that cannot hold seqMu — the fresh-connect path, whose buildReady + // rebuilds state from the DB afterwards, and the clientEvents path, which + // runs on the hub goroutine and so cannot race deliverBroadcast at all. + // The registration log line (a syscall-backed slog call) and + // updateKeyHolder (keyHolderMu plus a full h.clients scan under + // h.mu.RLock) both used to sit in that gap; both now run after the + // subscribes. Keeping the subscribes under h.mu is incidental but free: + // pubsub uses its own independent lock and never calls back into the hub, + // so h.mu → ps.mu adds no lock-ordering risk. h.pubsub.Subscribe(c, TopicGlobal) h.pubsub.Subscribe(c, UserTopic(c.userID)) // If the client already has a focused channel (e.g. test clients created with @@ -402,13 +481,38 @@ func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) { if chID := c.getChannelID(); chID != 0 { h.pubsub.Subscribe(c, ChannelTopic(chID)) } - // If the client is already in a voice channel (e.g. reconnect), re-subscribe - // to that channel's topic so the message stream keeps flowing without a new - // channel_focus. Voice membership is gated on CONNECT_VOICE alone, so it must - // not by itself grant a channel's message stream: subscribe only when the - // handshake confirmed READ_MESSAGES on that channel. - if voiceChID := c.getVoiceChID(); voiceChID != 0 && readableChannelIDs[voiceChID] { - h.pubsub.Subscribe(c, ChannelTopic(voiceChID)) + // If the client is already in a voice channel (e.g. reconnect), restore its + // subscriptions without a new voice_join (a same-channel rejoin is rejected + // with ALREADY_JOINED) or channel_focus. + if voiceChID := c.getVoiceChID(); voiceChID != 0 { + // VoiceTopic is the only transport for voice_e2ee_announce relays and + // carries nothing else, for a channel the user already joined via the + // CONNECT_VOICE-gated voice_join — so no READ gate. + h.pubsub.Subscribe(c, VoiceTopic(voiceChID)) + // Voice membership is gated on CONNECT_VOICE alone, so it must not by + // itself grant a channel's message stream: subscribe only when the + // handshake confirmed READ_MESSAGES on that channel. + if readableChannelIDs[voiceChID] { + h.pubsub.Subscribe(c, ChannelTopic(voiceChID)) + } + } + total := len(h.clients) + h.mu.Unlock() + + slog.Info("hub: client registered", "user_id", c.userID, "total_clients", total) + + // A fresh connect (lastSeq == 0) drops the replaced connection's voice state + // without transferring it, so that channel just lost a participant and the + // E2EE key holder may need to move. handleVoiceLeave never runs on this path + // — readPump skips it when replaced, and it early-returns on already-cleared + // state — so re-elect here. Must be outside h.mu: updateKeyHolder takes + // keyHolderMu and then h.mu.RLock. The recompute reads live client voice + // state, so it is idempotent and also correct when the state was transferred. + // It runs after the subscribe block above; updateKeyHolder only reads + // h.clients' voice state and writes voiceKeyHolders, so it has no + // ordering dependency on pub/sub subscriptions. + if replacedVoiceChID != 0 { + h.updateKeyHolder(replacedVoiceChID) } } @@ -423,7 +527,12 @@ func (h *Hub) unregisterNow(c *Client) bool { return false // not replaced } h.mu.Unlock() - return true // different client registered = was replaced + // exists means a *different* client holds the slot — a genuine replacement, + // whose teardown must not mark the live connection's user offline. An absent + // entry means this client was already kicked (every kick path deletes it via + // kickClient), which is a real disconnect and still needs the offline + // presence broadcast and voice cleanup in readPump's defer. + return exists } // ClientCount returns the number of currently registered clients (test helper). diff --git a/Server/ws/hub_broadcast.go b/Server/ws/hub_broadcast.go index 084f6676..7ad8d734 100644 --- a/Server/ws/hub_broadcast.go +++ b/Server/ws/hub_broadcast.go @@ -3,7 +3,6 @@ package ws import ( "context" "log/slog" - "sync/atomic" "github.com/owncord/server/db" "github.com/owncord/server/permissions" @@ -59,7 +58,25 @@ func (h *Hub) BroadcastToAll(msg []byte) { // The audience is resolved here, on the caller's goroutine, so the hub's // dispatch loop never blocks on permission lookups. func (h *Hub) broadcastVoiceEvent(ctx context.Context, channelID int64, msg []byte) { - h.broadcastChannelScoped(ctx, channelID, msg, "voice event") + // A room's own participants must always receive its voice_state / + // voice_leave: voice membership is gated on CONNECT_VOICE alone, so the + // READ filter can exclude a live participant — whose client then keeps a + // stale E2EE key holder, stalling rotation and locking new joiners out + // until e2ee_timeout. Union the READ audience with the room's current + // participants; what outsiders may observe is unchanged. + audience := h.channelReadAudience(ctx, channelID) + seen := make(map[int64]struct{}, len(audience)) + for _, uid := range audience { + seen[uid] = struct{}{} + } + h.mu.RLock() + for uid, c := range h.clients { + if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID { + audience = append(audience, uid) + } + } + h.mu.RUnlock() + h.broadcastChannelScopedTo(channelID, msg, audience, "voice event") } // broadcastChannelScoped enqueues msg for exactly the connected clients whose @@ -266,6 +283,35 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { return h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, userID, ch.ID, permissions.ReadMessages) } + // userCanSend mirrors channelCanSend (serve_ready.go) — the value the ready + // payload ships per channel — but expressed as per-user permission checks + // so it works in both the service and bare-hub branches without needing a + // resolved *db.Role. HasChannelPerm already bypasses for admins and fails + // closed on a lookup error, matching channelCanSend's own admin shortcut. + // + // Without this, can_send is only ever computed at connect time, so a role + // edit or override edit leaves every connected client's composer stuck on + // its stale connect-time verdict until the socket is rebuilt. + userCanSend := func(userID, roleID int64) bool { + has := func(perm int64) bool { + if h.perms != nil { + return h.perms.HasChannelPerm(ctx, userID, ch.ID, perm) + } + role, err := h.db.GetRoleByID(ctx, roleID) + if err != nil || role == nil { + return false + } + return h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, userID, ch.ID, perm) + } + if !has(permissions.ReadMessages) || !has(permissions.SendMessages) { + return false + } + if ch.Type == "announcement" { + return has(permissions.ManageMessages) + } + return true + } + for _, c := range clients { if c.user == nil { continue @@ -295,7 +341,10 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { } if visible { // Idempotent add on the client; also refreshes channel metadata. - c.sendMsg(buildChannelCreate(ch)) + // Addressed per client so it can carry this recipient's own + // can_send verdict — the whole point of this fan-out is that a + // permission change just made those verdicts diverge. + c.sendMsg(buildChannelCreateFor(ch, userCanSend(c.user.ID, c.user.RoleID))) continue } c.sendMsg(buildChannelDelete(ch.ID)) @@ -309,9 +358,11 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { // Clients not connected right now missed the targeted sends above. Move // the watermark so any resume from a seq at or before this point is - // forced onto the full-ready path instead of replay (stored after the - // sends so a concurrent seq advance errs toward re-syncing more clients). - h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq)) + // forced onto the full-ready path instead of replay. Ratcheted upward + // only — see bumpVisibilityWatermark — so a concurrent writer that read + // an older seq cannot regress a watermark another writer already pushed + // higher. + h.bumpVisibilityWatermark() } // RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every @@ -436,11 +487,14 @@ func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { // Only the topics the socket actually holds are examined — a blanket sweep over // every channel would disclose the full channel-ID list to a demoted user. func (h *Hub) revokeUnreadableChannels(userID int64) { - // Stored after the targeted sends (as in RefreshChannelVisibility) so a - // concurrent seq advance errs toward re-syncing more clients. Deferred - // because it must cover the early returns too: a user who is offline, or - // whose socket is closed below, converges via the full-ready path. - defer h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq)) + // Ratcheted upward only (see bumpVisibilityWatermark), and evaluated at + // defer-RUN time — not the plain Store(Load(&h.seq)) this used to be, + // whose argument would have been evaluated at this defer STATEMENT, + // capturing entry-time seq and stomping any higher watermark stored by a + // concurrent writer during the per-topic DB loop below. Deferred because + // it must cover the early returns too: a user who is offline, or whose + // socket is closed below, converges via the full-ready path. + defer h.bumpVisibilityWatermark() if h.db == nil { return @@ -540,10 +594,17 @@ func (h *Hub) BroadcastToAllLow(msg []byte) { h.pubsub.PublishGlobalLow(msg) } -// sendSequencedToUsersHigh stamps msg with a monotonic seq, stores it in the +// sendSequencedToUsers stamps msg with a monotonic seq, stores it in the // replay buffer under channelID, and fans the wrapped payload out to the -// provided users with high-priority delivery. -func (h *Hub) sendSequencedToUsersHigh(channelID int64, userIDs []int64, msg []byte) { +// provided users on the normal-priority queue. +// +// Sequenced frames must all share one per-client FIFO: writePump drains +// sendHigh before send, so a seq-stamped frame on the high queue would reach +// the socket ahead of lower-seq frames still queued in send. The client acks +// max(seq) and replay is strictly seq > last_seq, so a disconnect in that +// window would silently lose the overtaken events. The high queue remains for +// unsequenced targeted messages only. +func (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) { h.seqMu.Lock() defer h.seqMu.Unlock() @@ -553,7 +614,7 @@ func (h *Hub) sendSequencedToUsersHigh(channelID int64, userIDs []int64, msg []b h.persistEvent(seq, channelID, wrapped) for _, userID := range userIDs { - h.SendToUserHigh(userID, wrapped) + h.SendToUser(userID, wrapped) } } @@ -567,6 +628,18 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { h.seqMu.Lock() defer h.seqMu.Unlock() + // Channel-scoped sends consult the topic limiter BEFORE a seq is + // allocated: a shed frame that consumed a seq would sit in the replay + // buffer as a number no client ever saw live, and since clients ack + // only max(seq), it could never be requested back. + if bm.recipients == nil && bm.channelID != 0 { + if !h.topicLimiter.Allow(ChannelTopic(bm.channelID)) { + slog.Warn("hub: topic rate limit exceeded, dropping message", + "channel_id", bm.channelID) + return 0, 0, false + } + } + seq = h.nextSeq() msg := wrapWithSeq(bm.msg, seq) @@ -598,14 +671,10 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { // Global broadcast — deliver to every connected client. h.pubsub.PublishGlobal(msg) default: - // Channel-scoped broadcast — deliver to subscribers of the channel topic. - topic := ChannelTopic(bm.channelID) - if !h.topicLimiter.Allow(topic) { - slog.Warn("hub: topic rate limit exceeded, dropping message", - "channel_id", bm.channelID, "seq", seq) - return seq, 0, false - } - delivered = h.pubsub.Publish(topic, msg, 0) + // Channel-scoped broadcast — deliver to subscribers of the channel + // topic. The rate limiter already passed above, before the seq + // was allocated. + delivered = h.pubsub.Publish(ChannelTopic(bm.channelID), msg, 0) channelSend = true } return seq, delivered, channelSend diff --git a/Server/ws/hub_register_race_test.go b/Server/ws/hub_register_race_test.go new file mode 100644 index 00000000..812df178 --- /dev/null +++ b/Server/ws/hub_register_race_test.go @@ -0,0 +1,101 @@ +package ws + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// TestRegisterNow_ReplacementNeverLosesAConcurrentGlobalBroadcast locks the +// invariant serve.go's handleReconnect actually depends on: registerNow +// stripping the replaced connection's pub/sub subscriptions and re-subscribing +// the new one is itself two separate PubSub-lock acquisitions (UnsubscribeAll, +// then Subscribe), so calling it bare gives no atomicity guarantee against a +// concurrent deliverBroadcast — a global broadcast landing between those two +// acquisitions finds no subscriber for the user, and since deliverBroadcast +// already stamped it with a seq and pushed it to the replay buffer, it is +// unrecoverable: the resuming client's replay snapshot was taken even earlier, +// and the client only tracks max(seq), so the hole is silent and permanent. +// +// handleReconnect closes this by re-reading the replay tail and calling +// registerNow inside the SAME h.seqMu critical section deliverBroadcast uses +// for its entire body (seq allocation, replay push, and publish) — mutual +// exclusion on seqMu, not a narrowed timing window, is what actually +// eliminates the race. This test reproduces that exact pattern directly on +// Hub (without importing serve.go) and asserts it holds under real +// concurrency; a bare, unsynchronized registerNow call is deliberately NOT +// what is asserted here, since hub.go alone cannot provide that guarantee — +// only the seqMu-holding caller can. +func TestRegisterNow_ReplacementNeverLosesAConcurrentGlobalBroadcast(t *testing.T) { + h := newEmitTestHub() + go h.Run() + defer h.Stop() + + const userID = int64(1) + const iterations = 300 + + old := NewTestClient(h, userID, make(chan []byte, 8)) + h.mu.Lock() + h.clients[userID] = old + h.mu.Unlock() + h.pubsub.Subscribe(old, TopicGlobal) + + for i := range iterations { + replacement := NewTestClient(h, userID, make(chan []byte, 8)) + replacement.lastSeq = 1 // network reconnect path + + payload := fmt.Sprintf(`{"type":"server_restart","marker":%d}`, i) + // deliverBroadcast wraps every payload with an injected leading + // "seq" field (wrapWithSeq splices `{"seq":N,` in after the opening + // brace), so the frame that actually reaches a subscriber is never + // byte-equal to payload. Match on a brace-free fragment that survives + // the splice instead. + marker := fmt.Sprintf(`"marker":%d}`, i) + + done := make(chan struct{}) + go func() { + // Mirrors serve.go's handleReconnect: registerNow runs inside + // h.seqMu, the same lock deliverBroadcast holds for its whole + // critical section, so the two can never interleave. + h.seqMu.Lock() + h.registerNow(replacement, nil) + h.seqMu.Unlock() + close(done) + }() + h.BroadcastToAll([]byte(payload)) + <-done + + if !received(old.send, marker, 200*time.Millisecond) && + !received(replacement.send, marker, 200*time.Millisecond) { + t.Fatalf("iteration %d: broadcast %q reached neither the replaced nor the replacement connection", i, payload) + } + + old = replacement + } +} + +// received drains ch for up to timeout looking for a message containing want, +// returning true the moment it's found. Any other messages seen (e.g. from a +// previous iteration still in flight) are discarded. registerNow closes the +// replaced connection's send channel before this runs, and a closed buffered +// channel keeps yielding any messages queued before the close — but once +// drained, further receives return the zero value immediately without +// blocking, so a plain single-value receive would spin here for the whole +// timeout instead of reporting "channel closed and drained" right away. +func received(ch chan []byte, want string, timeout time.Duration) bool { + deadline := time.After(timeout) + for { + select { + case msg, ok := <-ch: + if !ok { + return false + } + if strings.Contains(string(msg), want) { + return true + } + case <-deadline: + return false + } + } +} diff --git a/Server/ws/hub_register_test.go b/Server/ws/hub_register_test.go new file mode 100644 index 00000000..14bfb3a0 --- /dev/null +++ b/Server/ws/hub_register_test.go @@ -0,0 +1,101 @@ +package ws + +import "testing" + +// A network reconnect (lastSeq > 0) replaces the old connection with a client +// that newClient builds with channelID == 0, and the client never re-sends +// channel_focus after a resume (mountChannel early-returns on the same +// channel). If registerNow does not transfer the old connection's focused +// channel, its ChannelTopic re-subscribe is a no-op and the user silently +// stops receiving chat_message until they manually switch channels. +func TestRegisterNow_ResumeTransfersFocusedChannel(t *testing.T) { + h := newEmitTestHub() + + old := NewTestClientWithChannel(h, 1, 7, make(chan []byte, 8)) + h.clients[1] = old + h.pubsub.Subscribe(old, ChannelTopic(7)) // as the channel_focus applier does + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 // network reconnect + h.registerNow(replacement, map[int64]bool{7: true}) + + if got := replacement.getChannelID(); got != 7 { + t.Errorf("focused channel not transferred on resume: got %d, want 7", got) + } + h.pubsub.mu.RLock() + sub := h.pubsub.topics[ChannelTopic(7)][1] + h.pubsub.mu.RUnlock() + if sub != replacement { + t.Error("resumed connection is not subscribed to its focused channel's topic") + } +} + +// The transfer is READ-gated like every other ChannelTopic subscription: if +// READ_MESSAGES was revoked between the drop and the resume, the replacement +// must not inherit the focused channel (fail closed). +func TestRegisterNow_ResumeFocusedChannelStaysReadGated(t *testing.T) { + h := newEmitTestHub() + + old := NewTestClientWithChannel(h, 1, 7, make(chan []byte, 8)) + h.clients[1] = old + h.pubsub.Subscribe(old, ChannelTopic(7)) + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 + h.registerNow(replacement, nil) // no READ_MESSAGES anywhere + + if got := replacement.getChannelID(); got != 0 { + t.Errorf("focused channel transferred without READ_MESSAGES: got %d, want 0", got) + } + h.pubsub.mu.RLock() + sub := h.pubsub.topics[ChannelTopic(7)][1] + h.pubsub.mu.RUnlock() + if sub != nil { + t.Error("channel topic subscription survived a resume without READ_MESSAGES") + } +} + +// A dying connection's in-flight handler (e.g. a channel_focus mid DB +// round-trip in its readPump) can call Subscribe after registerNow stripped +// the old client via UnsubscribeAll — stealing the topic from the +// replacement: the replacement's own unsubscribes then skip the entry +// (unsubscribeLocked's identity guard) while publishes go to the closed +// connection. Subscribe must refuse a client whose send is already closed. +func TestSubscribe_RefusesReplacedClientWithClosedSend(t *testing.T) { + h := newEmitTestHub() + + old := NewTestClient(h, 1, make(chan []byte, 8)) + h.clients[1] = old + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 + h.registerNow(replacement, nil) // closes old's send channels + + // The old connection's handler completes its Subscribe late. + h.pubsub.Subscribe(old, ChannelTopic(7)) + + h.pubsub.mu.RLock() + sub := h.pubsub.topics[ChannelTopic(7)][1] + h.pubsub.mu.RUnlock() + if sub == old { + t.Error("closed connection stole the topic subscription from its replacement") + } +} + +// A fresh connect (lastSeq == 0, e.g. F5) reloads the client app, which mounts +// its channel and sends channel_focus itself — the focused channel must not be +// inherited server-side, matching the voice-state semantics on this path. +func TestRegisterNow_FreshConnectDoesNotInheritFocusedChannel(t *testing.T) { + h := newEmitTestHub() + + old := NewTestClientWithChannel(h, 1, 7, make(chan []byte, 8)) + h.clients[1] = old + h.pubsub.Subscribe(old, ChannelTopic(7)) + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + h.registerNow(replacement, map[int64]bool{7: true}) + + if got := replacement.getChannelID(); got != 0 { + t.Errorf("fresh connect inherited a focused channel: got %d, want 0", got) + } +} diff --git a/Server/ws/hub_sweep.go b/Server/ws/hub_sweep.go index a5f4bcac..fc4b3b72 100644 --- a/Server/ws/hub_sweep.go +++ b/Server/ws/hub_sweep.go @@ -16,6 +16,14 @@ import ( // a ping every 30s, so 90s (3x) gives plenty of margin. const staleClientTimeout = 90 * time.Second +// onStaleTick runs the cheap in-memory maintenance driven by the stale ticker. +func (h *Hub) onStaleTick() { + h.sweepStaleClients() + // Per-channel token buckets are created on first broadcast; prune idle + // ones here or the bucket map grows for the process lifetime. + h.topicLimiter.Cleanup(10 * time.Minute) +} + // kickClient forcibly removes a client from the hub and closes its send channel, // which causes writePump to exit and the WebSocket connection to close. // It is safe to call from any goroutine. @@ -25,8 +33,12 @@ func (h *Hub) kickClient(c *Client) { delete(h.clients, c.userID) } h.mu.Unlock() - h.pubsub.UnsubscribeAll(c) + // closeSend BEFORE UnsubscribeAll: Subscribe's only re-take guard is + // isSendClosed, so a Subscribe racing this kick either lands before the + // close (and UnsubscribeAll below removes it) or sees the closed channel + // and refuses. The reverse order leaves the dead client holding the topic. c.closeSend() + h.pubsub.UnsubscribeAll(c) } // startSweep runs sweep on its own goroutine so the hub dispatch loop never @@ -147,13 +159,35 @@ func (h *Hub) sweepStaleVoiceStates() { h.mu.RUnlock() for _, c := range inVoice { chID := c.getVoiceChID() - if chID == 0 || h.hasChannelPerm(ctx, c, chID, permissions.ConnectVoice) { + if chID == 0 { continue } - slog.Warn("sweepStaleVoiceStates: evicting participant whose CONNECT_VOICE was revoked", + allowed, err := h.hasChannelPermChecked(ctx, c.userID, chID, permissions.ConnectVoice) + if err != nil { + // A transient read failure (I/O error, lock contention, a + // maintenance window) is not a revocation — hasChannelPerm and + // permissions.Checker.HasChannelPerm both collapse any DB error + // to "denied", which would otherwise evict every in-voice + // participant on one bad read. Skip this client this tick; the + // next tick retries. Mirrors sweepRevokedSessions' guard on its + // own batch lookup below. + slog.Warn("sweepStaleVoiceStates: permission check failed, skipping this tick", + "user_id", c.userID, "channel_id", chID, "err", err) + continue + } + if allowed { + continue + } + // The permission check is a DB round-trip; a voice_join to a + // still-permitted channel may have committed while it ran. The + // eviction is conditional on the client still being in the checked + // channel — never on whatever channel it is in by now. + if !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { + continue + } + slog.Warn("sweepStaleVoiceStates: evicted participant whose CONNECT_VOICE was revoked", "user_id", c.userID, "channel_id", chID) c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing CONNECT_VOICE permission")) - h.handleVoiceLeave(ctx, c) } allStates, err := h.db.GetAllVoiceStates(ctx) @@ -199,12 +233,58 @@ func (h *Hub) sweepStaleVoiceStates() { slog.Warn("sweepStaleVoiceStates: removed ghost voice state", "user_id", s.userID, "channel_id", s.channelID) h.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID)) + // Re-elect the key holder now that this ghost row is gone — every + // other path that removes a voice participant does this + // (finishVoiceLeave, the LiveKit webhook, registerNow, + // handleVoiceJoin). Without it, a departed user stripped out here + // while still named as key holder leaves the remaining lowest-uid + // participant self-promoting and rotating the room key locally, and + // its voice_e2ee_offers are rejected with NOT_KEY_HOLDER until the + // next join/leave in the channel. No locks are held here, matching + // the webhook call site. + h.updateKeyHolder(s.channelID) if h.livekit != nil { _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) } } } +// hasChannelPermChecked is hasChannelPerm's error-aware counterpart: it +// distinguishes a genuine permission denial (role missing, or the effective +// permission bits don't include perm) from a DB read failure, by inlining the +// same resolution hasChannelPerm/permissions.Checker.HasChannelPerm perform — +// both of which collapse any error into "denied", indistinguishable from a +// real revocation. sweepStaleVoiceStates needs that distinction: unlike a +// handler answering one client's request, it evicts a live voice session on +// "denied", so a transient read failure must not be treated as a revocation. +func (h *Hub) hasChannelPermChecked(ctx context.Context, userID, channelID int64, perm int64) (allowed bool, err error) { + role, err := h.db.GetRoleForUser(ctx, userID) + if err != nil { + return false, err + } + if role == nil { + // No role row is a genuine deny, not an error — mirrors + // hasChannelPerm's role == nil case. + return false, nil + } + if permissions.HasAdmin(role.Permissions) { + return true, nil + } + allow, deny, err := h.db.GetChannelPermissions(ctx, channelID, role.ID) + if err != nil { + return false, err + } + o := permissions.ChannelOverride{Allow: allow, Deny: deny} + if userID != 0 { + uAllow, uDeny, uErr := h.db.GetUserChannelPermissions(ctx, channelID, userID) + if uErr != nil { + return false, uErr + } + o.UserAllow, o.UserDeny = uAllow, uDeny + } + return permissions.EffectiveChannelPerms(role.Permissions, o)&perm == perm, nil +} + // CleanupVoiceForChannel removes all voice participants from the given channel. // Called when a channel is deleted. func (h *Hub) CleanupVoiceForChannel(channelID int64) { @@ -220,18 +300,22 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { return } - // Clean up DB state and LiveKit for each participant. + // Clean up DB state and LiveKit for each participant. Both the row delete + // and the client-state clear are conditional on the participant still + // being in THIS channel: a user who moved to another voice channel + // between the snapshot above and this loop must not be clobbered. for _, vs := range states { - if err := h.db.LeaveVoiceChannel(ctx, vs.UserID); err != nil { - slog.Error("CleanupVoiceForChannel LeaveVoiceChannel", "err", err, "user_id", vs.UserID, "channel_id", channelID) + if _, err := h.db.LeaveVoiceChannelIfMatch(ctx, vs.UserID, channelID, vs.JoinedAt); err != nil { + slog.Error("CleanupVoiceForChannel LeaveVoiceChannelIfMatch", "err", err, "user_id", vs.UserID, "channel_id", channelID) } - // Clear client voice state. + // Clear client voice state and its voice-topic subscription. h.mu.RLock() - if client, ok := h.clients[vs.UserID]; ok { - client.clearVoiceChID() - } + client, ok := h.clients[vs.UserID] h.mu.RUnlock() + if ok && client.getVoiceChID() == channelID { + h.clearVoiceAndUnsubscribe(client) + } // Remove from LiveKit (best-effort). if h.livekit != nil { @@ -241,7 +325,21 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { // Broadcast voice_leave for each participant. All leaves target the same // channel, so resolve the READ audience once and reuse it per message. + // The evicted participants themselves must always be in it (their client + // state is already cleared, so broadcastVoiceEvent's participant union + // cannot see them): the voice_leave is what drives their own E2EE + // teardown, and voice membership never required READ_MESSAGES. audience := h.channelReadAudience(ctx, channelID) + seen := make(map[int64]struct{}, len(audience)) + for _, uid := range audience { + seen[uid] = struct{}{} + } + for _, vs := range states { + if _, ok := seen[vs.UserID]; !ok { + seen[vs.UserID] = struct{}{} + audience = append(audience, vs.UserID) + } + } for _, vs := range states { h.broadcastChannelScopedTo(channelID, buildVoiceLeave(channelID, vs.UserID), audience, "voice event") } diff --git a/Server/ws/hub_sweep_test.go b/Server/ws/hub_sweep_test.go index 2b20b49c..7f9bde2a 100644 --- a/Server/ws/hub_sweep_test.go +++ b/Server/ws/hub_sweep_test.go @@ -1,9 +1,12 @@ package ws import ( + "context" "sync/atomic" "testing" "time" + + "github.com/owncord/server/auth" ) // TestStartSweep_NeverRunsConcurrentlyWithItself locks the in-flight guard @@ -62,3 +65,136 @@ func TestStartSweep_NeverRunsConcurrentlyWithItself(t *testing.T) { t.Fatalf("blocking sweep ran %d times, want 1", got) } } + +// Every kick path (the sweeps, the handlers.go expiry/ban kicks, DisconnectUser) +// deletes the hub entry via kickClient, so the readPump defer's unregisterNow +// finds nothing. "Absent" is a real disconnect, not a replacement: reporting it +// as replaced makes readPump skip MarkUserDisconnected, the offline presence +// broadcast, and handleVoiceLeave, so peers keep rendering the kicked user +// online. +func TestUnregisterNow_KickedClientIsNotReportedAsReplaced(t *testing.T) { + h := newEmitTestHub() + c := NewTestClient(h, 1, make(chan []byte, 4)) + h.clients[1] = c + + h.kickClient(c) + + if replaced := h.unregisterNow(c); replaced { + t.Error("unregisterNow(kicked client) = true (replaced), want false (real disconnect)") + } +} + +// The genuine replacement case must keep reporting true, so a reconnect's +// teardown does not mark the live connection's user offline. +func TestUnregisterNow_ReplacedClientIsReportedAsReplaced(t *testing.T) { + h := newEmitTestHub() + old := NewTestClient(h, 1, make(chan []byte, 4)) + live := NewTestClient(h, 1, make(chan []byte, 4)) + h.clients[1] = live // the reconnect already took the slot + + if replaced := h.unregisterNow(old); !replaced { + t.Error("unregisterNow(old client) = false, want true (a live client holds the slot)") + } + if _, ok := h.clients[1]; !ok { + t.Error("unregisterNow(old client) evicted the live client from the hub") + } +} + +// TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict locks the +// fail-open-on-error behavior sweepStaleVoiceStates must have: a DB read +// failure on the CONNECT_VOICE check (as opposed to a genuine revocation) must +// leave the client in voice, mirroring sweepRevokedSessions' own guard against +// treating a transient batch-lookup error as a mass disconnect. Before the +// fix, hasChannelPerm collapsed any GetChannelPermissions error to "denied", +// so a read-path fault alone evicted every in-voice participant. +func TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + uid := seedHarvestVoiceUser(t, database, "sweep-transient-err") + chID := mustCreateVoiceChannel(t, database, "voice-transient-err") + if err := database.JoinVoiceChannel(ctx, uid, chID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + c := NewTestClient(h, uid, make(chan []byte, 8)) + c.setVoiceState(chID, "tok") + h.clients[uid] = c + + // Fault-inject exactly the permission read: harvestVoiceRoleID grants + // CONNECT_VOICE directly on the role, so hasChannelPermChecked must reach + // GetChannelPermissions (channel_overrides) before it can resolve — + // nobody's permissions actually changed. + if _, err := database.ExecContext(ctx, `ALTER TABLE channel_overrides RENAME TO channel_overrides_offline`); err != nil { + t.Fatalf("rename channel_overrides: %v", err) + } + + h.sweepStaleVoiceStates() + + if got := c.getVoiceChID(); got != chID { + t.Fatalf("client voice channel = %d after a transient permission-read error, want it to stay at %d (not evicted)", got, chID) + } + vs, err := database.GetVoiceState(ctx, uid) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if vs == nil { + t.Error("voice_states row was deleted after a transient permission-read error, want it to survive") + } +} + +// TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder locks the sweep's +// ghost-row branch into re-electing the key holder, matching every sibling +// removal path (finishVoiceLeave, the LiveKit webhook, registerNow, +// handleVoiceJoin). Before the fix, the ghost branch deleted the row and +// broadcast voice_leave without calling updateKeyHolder, so a departed user +// stripped out here while still named as key holder left the remaining +// participant self-promoting and rotating the room key locally, with its +// voice_e2ee_offers rejected as NOT_KEY_HOLDER. +func TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder(t *testing.T) { + ctx := context.Background() + database := newHarvestVoiceDB(t) + // Ghost has the lower userID, so it would win election if it were still a + // candidate — the test only proves anything if survivor isn't already the + // answer regardless of re-election. + ghostUID := seedHarvestVoiceUser(t, database, "sweep-ghost-holder-a") + survivorUID := seedHarvestVoiceUser(t, database, "sweep-ghost-holder-b") + if ghostUID > survivorUID { + t.Fatalf("test setup assumes ghostUID (%d) < survivorUID (%d)", ghostUID, survivorUID) + } + chID := mustCreateVoiceChannel(t, database, "voice-ghost-holder") + + // Ghost: a real voice_states row, but no connected client — the exact + // state sweepStaleVoiceStates' second loop treats as a ghost. + if err := database.JoinVoiceChannel(ctx, ghostUID, chID); err != nil { + t.Fatalf("JoinVoiceChannel(ghost): %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + survivor := NewTestClient(h, survivorUID, make(chan []byte, 8)) + survivor.setVoiceState(chID, "tok-survivor") + h.clients[survivorUID] = survivor + + // Simulate the stale-holder precondition from the finding: the ghost is + // still recorded as key holder (e.g. from before it dropped out of + // h.clients), and the survivor has not yet been elected. + h.keyHolderMu.Lock() + h.voiceKeyHolders[chID] = ghostUID + h.keyHolderMu.Unlock() + + h.sweepStaleVoiceStates() + + if h.IsVoiceKeyHolder(chID, ghostUID) { + t.Error("ghost user is still recorded as key holder after the sweep removed its ghost voice state") + } + if !h.IsVoiceKeyHolder(chID, survivorUID) { + t.Error("surviving participant was not re-elected key holder after the sweep removed the ghost") + } + vs, err := database.GetVoiceState(ctx, ghostUID) + if err != nil { + t.Fatalf("GetVoiceState(ghost): %v", err) + } + if vs != nil { + t.Error("ghost voice_states row was not removed by the sweep") + } +} diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index ee6b43e5..ed1bd666 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -1087,6 +1087,76 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { assertNoMsgType(t, memberSend, "channel_delete") } +// can_send used to be computed only in the ready payload, so a mid-session +// permission edit left a connected client's composer on its stale connect-time +// verdict until the socket was rebuilt. The targeted channel_create this +// fan-out sends now carries each recipient's own verdict — and because it is +// per-recipient, two clients must be able to receive different answers from +// the same RefreshChannelVisibility call. +func TestRefreshChannelVisibility_TargetedCreateCarriesPerClientCanSend(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "cansend-room") + ch, err := database.GetChannel(context.Background(), chID) + if err != nil || ch == nil { + t.Fatalf("GetChannel: %v", err) + } + + owner := seedOwnerUser(t, database, "cansend-owner") + memberID := seedTestUser(t, database, "cansend-member") + member, err := database.GetUserByID(context.Background(), memberID) + if err != nil || member == nil { + t.Fatalf("GetUserByID: %v", err) + } + + ownerSend := make(chan []byte, 16) + memberSend := make(chan []byte, 16) + ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend) + memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend) + hub.Register(ownerClient) + hub.Register(memberClient) + waitRegistered(t, hub, memberClient) + + // Member keeps READ_MESSAGES (0x0002) but loses SEND_MESSAGES (0x0001), so + // the channel stays VISIBLE while posting is revoked — precisely the case a + // visibility-only fan-out used to leave with a stale composer. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 1)`, + chID, + ); err != nil { + t.Fatalf("insert override: %v", err) + } + + hub.RefreshChannelVisibility(ch) + + memberMsg := drainForMsgType(t, memberSend, "channel_create") + memberPayload, ok := memberMsg["payload"].(map[string]any) + if !ok { + t.Fatalf("member channel_create payload not an object: %#v", memberMsg["payload"]) + } + canSend, present := memberPayload["can_send"] + if !present { + t.Fatal("targeted channel_create omitted can_send — the client keeps its stale connect-time verdict") + } + if canSend != false { + t.Fatalf("member can_send = %v, want false (SEND_MESSAGES denied)", canSend) + } + + // Same call, same channel, different recipient: the owner's admin bit must + // still yield true, proving the value is resolved per client rather than + // encoded once for the whole audience. + ownerMsg := drainForMsgType(t, ownerSend, "channel_create") + ownerPayload, ok := ownerMsg["payload"].(map[string]any) + if !ok { + t.Fatalf("owner channel_create payload not an object: %#v", ownerMsg["payload"]) + } + if ownerPayload["can_send"] != true { + t.Fatalf("owner can_send = %v, want true (admin bypass)", ownerPayload["can_send"]) + } +} + func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) { hub, database := newTestHub(t) diff --git a/Server/ws/hub_visibility_watermark_test.go b/Server/ws/hub_visibility_watermark_test.go new file mode 100644 index 00000000..ad62a4e6 --- /dev/null +++ b/Server/ws/hub_visibility_watermark_test.go @@ -0,0 +1,142 @@ +package ws + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/owncord/server/db" +) + +// TestBumpVisibilityWatermark_RatchetsUpwardOnly locks the core invariant all +// three visibilityChangeSeq writers now depend on: the watermark only ever +// moves forward, mirroring SeedSeq's CAS-max pattern. Before the fix, every +// writer did a plain Store(Load(&h.seq)), so a writer that observed an older +// h.seq could clobber a watermark another writer had already pushed higher. +func TestBumpVisibilityWatermark_RatchetsUpwardOnly(t *testing.T) { + h := &Hub{} + + atomic.StoreUint64(&h.seq, 100) + h.bumpVisibilityWatermark() + if got := h.visibilityChangeSeq.Load(); got != 100 { + t.Fatalf("watermark = %d, want 100", got) + } + + // A later caller observing a LOWER h.seq (e.g. a writer that read it + // before a concurrent bump advanced it further) must not regress the + // watermark another writer already pushed higher. + atomic.StoreUint64(&h.seq, 50) + h.bumpVisibilityWatermark() + if got := h.visibilityChangeSeq.Load(); got != 100 { + t.Fatalf("watermark regressed to %d after a lower bump, want it to stay at 100", got) + } + + // A genuine advance still moves the watermark forward. + atomic.StoreUint64(&h.seq, 150) + h.bumpVisibilityWatermark() + if got := h.visibilityChangeSeq.Load(); got != 150 { + t.Fatalf("watermark = %d after a genuine advance, want 150", got) + } +} + +// TestBumpVisibilityWatermark_ConcurrentCallsNeverRegress hammers the ratchet +// from many goroutines with h.seq advancing concurrently and asserts the +// final watermark is never below any value observed mid-run — i.e. it only +// ever moves forward, regardless of goroutine interleaving. +func TestBumpVisibilityWatermark_ConcurrentCallsNeverRegress(t *testing.T) { + h := &Hub{} + atomic.StoreUint64(&h.seq, 1) + + const goroutines = 32 + done := make(chan struct{}) + for range goroutines { + go func() { + defer func() { done <- struct{}{} }() + atomic.AddUint64(&h.seq, 1) + h.bumpVisibilityWatermark() + }() + } + for range goroutines { + <-done + } + + finalSeq := atomic.LoadUint64(&h.seq) + if got := h.visibilityChangeSeq.Load(); got != finalSeq { + t.Fatalf("watermark = %d after concurrent bumps, want %d (the final seq)", got, finalSeq) + } +} + +// TestRevokeUnreadableChannels_WatermarkNeverRegresses targets the specific +// defect: revokeUnreadableChannels used `defer h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq))`, +// whose argument Go evaluates at the DEFER STATEMENT (function entry), not at +// the deferred call (function exit) — so it stored a stale entry-time seq, +// silently overwriting any higher watermark a concurrent writer (e.g. a DM +// open) stored while this function did its per-topic DB work. Simulated here +// by pre-seeding the watermark above h.seq, standing in for "a concurrent +// writer already pushed the watermark past this function's entry-time seq". +func TestRevokeUnreadableChannels_WatermarkNeverRegresses(t *testing.T) { + h := newEmitTestHub() + atomic.StoreUint64(&h.seq, 100) + // Simulate a concurrent writer (DMChannelOpenEvent) that already bumped + // the watermark past the current seq before this call starts. + h.visibilityChangeSeq.Store(500) + + // h.db is nil, so revokeUnreadableChannels returns immediately after the + // deferred bump runs — exercising exactly the regression path. + h.revokeUnreadableChannels(1) + + if got := h.visibilityChangeSeq.Load(); got != 500 { + t.Fatalf("watermark = %d after revokeUnreadableChannels, want it to stay at 500 (must not regress)", got) + } +} + +// TestRefreshChannelVisibility_WatermarkNeverRegresses is RefreshChannelVisibility's +// counterpart to the above: its trailing watermark bump must also ratchet +// upward only, not clobber a higher value a concurrent writer already stored. +func TestRefreshChannelVisibility_WatermarkNeverRegresses(t *testing.T) { + h := newEmitTestHub() + atomic.StoreUint64(&h.seq, 100) + h.visibilityChangeSeq.Store(500) + + h.RefreshChannelVisibility(&db.Channel{ID: 1, Type: "text"}) + + if got := h.visibilityChangeSeq.Load(); got != 500 { + t.Fatalf("watermark = %d after RefreshChannelVisibility, want it to stay at 500 (must not regress)", got) + } +} + +// TestEmitEvents_DMChannelOpen_WatermarkNeverRegresses is emit.go's +// UserTargetedEvent/DMChannelOpenEvent branch's counterpart: it must also +// route through the same upward-only ratchet instead of a plain Store. +func TestEmitEvents_DMChannelOpen_WatermarkNeverRegresses(t *testing.T) { + h := newEmitTestHub() + atomic.StoreUint64(&h.seq, 100) + h.visibilityChangeSeq.Store(500) + + h.EmitEvents(context.Background(), []Event{ + DMChannelOpenEvent{targetUserID: 7, payload: []byte(`{"type":"dm_channel_open"}`)}, + }) + + if got := h.visibilityChangeSeq.Load(); got != 500 { + t.Fatalf("watermark = %d after DMChannelOpenEvent, want it to stay at 500 (must not regress)", got) + } +} + +// Sanity check that the ratchet does not break the original, non-racing +// behaviour these functions must still provide: a genuine, later seq still +// moves the watermark forward so mustFullResync keeps working. +func TestEmitEvents_DMChannelOpen_WatermarkStillAdvances(t *testing.T) { + h := newEmitTestHub() + atomic.StoreUint64(&h.seq, 40) + + h.EmitEvents(context.Background(), []Event{ + DMChannelOpenEvent{targetUserID: 7, payload: []byte(`{"type":"dm_channel_open"}`)}, + }) + + if !h.mustFullResync(40) { + t.Error("a client resuming from a seq at or before a dm_channel_open must be forced onto the full-ready path") + } + if h.mustFullResync(41) { + t.Error("clients past the open must keep replaying normally") + } +} diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 131c62e1..e20bf86b 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -31,7 +31,7 @@ type LiveKitProcess struct { cmd *exec.Cmd cancel context.CancelFunc stopped bool - runDone chan struct{} // closed by runLoop when cmd.Run() returns + runDone chan struct{} // closed by runLoop when cmd.Wait() returns loopDone chan struct{} // closed when runLoop exits entirely } @@ -258,22 +258,30 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath, binPath string) { cmd.Stderr = os.Stderr cmd.WaitDelay = 6 * time.Second // bound Wait to prevent goroutine leak on Windows - p.mu.Lock() - if p.stopped { - p.mu.Unlock() - return - } - p.cmd = cmd - p.runDone = make(chan struct{}) - p.mu.Unlock() - slog.Info("livekit: starting process", "binary", binPath, "config", cfgPath, "rapid_failures", rapidFailures) startTime := time.Now() - err := cmd.Run() + p.mu.Lock() + if p.stopped { + p.mu.Unlock() + return + } + // Start inside the critical section that publishes p.cmd: Start is + // what writes cmd.Process, which IsRunning and Stop read under p.mu — + // started after the unlock, that write races every such read. + err := cmd.Start() + if err == nil { + p.cmd = cmd + p.runDone = make(chan struct{}) + } + p.mu.Unlock() + + if err == nil { + err = cmd.Wait() + } p.mu.Lock() p.cmd = nil @@ -356,9 +364,9 @@ func (p *LiveKitProcess) HealthCheck(ctx context.Context) (bool, error) { // Stop gracefully stops the companion process. // It cancels the context (which signals runLoop) and waits up to 5 seconds -// for the process to exit. The actual cmd.Wait() is done by runLoop via -// cmd.Run() — we only monitor the process via cmd.Process.Wait() here to -// avoid calling exec.Cmd.Wait() twice (which has undefined behavior). +// for the process to exit. The actual cmd.Wait() is done by runLoop — we only +// monitor the process here to avoid calling exec.Cmd.Wait() twice (which has +// undefined behavior). func (p *LiveKitProcess) Stop() { p.mu.Lock() p.stopped = true @@ -372,7 +380,7 @@ func (p *LiveKitProcess) Stop() { cancel() } - // Wait for runLoop's cmd.Run() to return (which closes runDone). + // Wait for runLoop's cmd.Wait() to return (which closes runDone). // This avoids calling cmd.Wait() or cmd.Process.Wait() from a second // goroutine, which is unsafe on Windows. if done != nil { diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index ea4f04f6..aa392c01 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -516,6 +516,55 @@ func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing. } } +// TestWebhook_ParticipantLeft_ClearsE2EEState_OnMatch locks a correctness +// detail of the v050 fix: handleWebhookParticipantLeft now clears the +// client's voice state via an atomic compare-and-clear (both channel and +// join token checked under voiceMu in one critical section) instead of two +// independent unlocked reads followed by an unconditional clear. This test +// pins the matching-case behavior of the rewrite: it must still clear +// e2eePubKey/e2eeSignature exactly like the old clearVoiceState-based path +// did, not just voiceChID/voiceJoinToken — otherwise a departed +// participant's stale ECDH key lingers on the connection and pollutes a +// later voice session's peer-key store. +func TestWebhook_ParticipantLeft_ClearsE2EEState_OnMatch(t *testing.T) { + t.Parallel() + hub, database := newVoiceHub(t) + + user := seedVoiceOwner(t, database, "webhook-e2ee-user") + chanID := seedVoiceChannel(t, database, "webhook-e2ee-ch") + + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + vs, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState: %v (nil=%v)", err, vs == nil) + } + + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, user.ID, send) + ws.SetClientVoiceStateForTest(c, chanID, vs.JoinedAt) + ws.SetClientE2EEPubKeyForTest(c, "fake-ecdh-pubkey") + hub.RegisterNowForTest(c) + + hub.HandleWebhookParticipantLeftForTest(user.ID, chanID, vs.JoinedAt) + + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Errorf("voice channel = %d after matching webhook cleanup, want 0", got) + } + if got := ws.GetClientE2EEPubKeyForTest(c); got != "" { + t.Errorf("E2EE pub key = %q after matching webhook cleanup, want cleared", got) + } + + dbState, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState after webhook: %v", err) + } + if dbState != nil { + t.Error("voice_states row still present after matching webhook cleanup") + } +} + // --------------------------------------------------------------------------- // livekit_process.go – generateConfig tests // --------------------------------------------------------------------------- diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index c44f4633..c323d03e 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -188,25 +188,48 @@ func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.W h.mu.RUnlock() if exists { - currentChID, currentJoinToken := c.getVoiceState() - if currentChID == channelID && currentJoinToken != "" && currentJoinToken == joinToken { - // Double-check voice state to guard against a concurrent voice_join - // that updated the state between the read above and clearVoiceState (L8). - if reChID, reJT := c.getVoiceState(); reChID == channelID && reJT == joinToken { - c.clearVoiceState() + // Atomic compare-and-clear under c.voiceMu, replacing the previous + // read-then-read-then-clear: two independent unlocked getVoiceState + // snapshots followed by an unconditional clearVoiceState is not a + // guard at all — no lock spans the second read and the clear, so a + // voice_join committed on the readPump goroutine in between (a + // channel switch, or a same-channel rejoin with a fresh token) is + // wiped out from under the new session, dropping its VoiceTopic + // subscription along with it. client.go's clearVoiceStateIfMatch + // only compares the channel, not the token, so it would still be + // fooled by a same-channel rejoin — this compares both, inlined here + // via direct field access (same package as client.go) under the + // client's own voiceMu. + c.voiceMu.Lock() + matched := c.voiceChID == channelID && c.voiceJoinToken != "" && c.voiceJoinToken == joinToken + if matched { + c.voiceChID = 0 + c.voiceJoinToken = "" + c.e2eePubKey = "" + c.e2eeSignature = "" + } + c.voiceMu.Unlock() - if h.db != nil { - if err := leaveVoiceChannelWithRetry(ctx, h, userID, channelID, joinToken); err != nil { - slog.Error("livekit webhook: LeaveVoiceChannel exhausted retries", - "error", err, "user_id", userID, "channel_id", channelID) - } + if matched { + h.pubsub.Unsubscribe(c, VoiceTopic(channelID)) + + if h.db != nil { + if err := leaveVoiceChannelWithRetry(ctx, h, userID, channelID, joinToken); err != nil { + slog.Error("livekit webhook: LeaveVoiceChannel exhausted retries", + "error", err, "user_id", userID, "channel_id", channelID) } - - h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID)) - slog.Info("livekit webhook: cleaned up stale voice state", - "user_id", userID, - "channel_id", channelID) } + + // This participant is out of voice, so the E2EE key holder may + // need to move. Without this the map keeps naming the departed + // user and the real lowest-uid participant's rekey offers are + // rejected with NOT_KEY_HOLDER. Safe here: no locks are held. + h.updateKeyHolder(channelID) + + h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID)) + slog.Info("livekit webhook: cleaned up stale voice state", + "user_id", userID, + "channel_id", channelID) } else if h.db != nil { // Client has voiceChID=0 or moved to a different channel (e.g. // after F5 reload), or this webhook is for an older join instance. diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index dc258fd9..9272a183 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -34,6 +34,7 @@ const ( MsgTypeVoiceE2EEOffer = "voice_e2ee_offer" MsgTypeCallRing = "call_ring" MsgTypeCallDecline = "call_decline" + MsgTypeChatCommand = "chat_command" // plugin slash-command dispatch (Phase C) ) // Server → Client message types (sent in broadcasts/responses). @@ -75,4 +76,6 @@ const ( MsgTypeCallDeclined = "call_declined" MsgTypeVoiceE2EEAnnounceBC = "voice_e2ee_announce" // broadcast (same string as client msg) MsgTypeVoiceE2EEOfferRelay = "voice_e2ee_offer" // relay (same string as client msg) + MsgTypeCommandReply = "command_reply" // ephemeral plugin reply, sent only to the invoking client + MsgTypePluginBroadcast = "plugin_broadcast" // plugin channel broadcast, gated by the sender's SEND_MESSAGES ) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 38f6193c..d708ae3f 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -264,6 +264,17 @@ type channelPayload struct { // show "3/5" and the client can explain a refusal it could have predicted. VoiceMaxUsers int `json:"voice_max_users"` VoiceMaxVideo int `json:"voice_max_video"` + // CanSend is the per-recipient composer affordance, the same value the + // ready payload ships per channel. It is per-client, so only the targeted + // sends in RefreshChannelVisibility populate it — the shared-buffer + // broadcasts (BroadcastChannelCreate/Update) leave it nil, since one + // encoded frame is delivered to every recipient and a single value would + // be wrong for some of them. + // + // Pointer + omitempty so "not stated" stays distinguishable from "false": + // a client must keep its existing verdict when the field is absent, and an + // older server that never sends it keeps the permissive default. + CanSend *bool `json:"can_send,omitempty"` } // channelPayloadFrom narrows a channel row to the wire shape shared by the @@ -705,6 +716,21 @@ func buildChannelCreate(ch *db.Channel) []byte { }) } +// buildChannelCreateFor constructs a channel_create addressed to ONE client, +// carrying that client's can_send verdict. +// +// Separate from buildChannelCreate because can_send is per-recipient: the +// broadcast form encodes a single frame for a whole audience, so it must leave +// the field absent rather than assert one client's answer for everyone. +func buildChannelCreateFor(ch *db.Channel, canSend bool) []byte { + p := channelPayloadFrom(ch) + p.CanSend = &canSend + return buildJSON(wsMsg{ + Type: MsgTypeChannelCreate, + Payload: p, + }) +} + // buildChannelUpdate constructs a channel_update broadcast. func buildChannelUpdate(ch *db.Channel) []byte { return buildJSON(wsMsg{ diff --git a/Server/ws/protocol_contract_test.go b/Server/ws/protocol_contract_test.go index 2dc99239..d4665e7d 100644 --- a/Server/ws/protocol_contract_test.go +++ b/Server/ws/protocol_contract_test.go @@ -15,14 +15,13 @@ package ws_test // - every wire value the schema lists has a matching Go constant with the // schema's stated name and value ("schema -> code"), and // - every MsgType* constant declared anywhere in the ws package appears in -// the schema ("code -> schema"), with ONE documented exception. +// the schema ("code -> schema"). // -// The exception: MsgTypeChatCommand ("chat_command", ws/handlers_command.go) -// is a client->server wire message with no schema entry. It predates the -// schema/genprotocol pipeline and was deliberately left out — plugin slash -// commands are dispatched through the plugin registry rather than the fixed -// handler table the generated constants serve, so it was judged internal -// wiring rather than part of the documented protocol surface. If a second +// The exception list below is empty and should stay that way. Its one +// historical entry, MsgTypeChatCommand, predated the schema/genprotocol +// pipeline (plugin slash commands were judged internal wiring); the 2026-08-04 +// remediation moved the whole plugin command family (chat_command, +// command_reply, plugin_broadcast) into the schema, closing DC-01. If an // undocumented constant ever appears, this test fails and names it — that is // the drift this test exists to catch, not something to silently allowlist. @@ -39,9 +38,7 @@ import ( "testing" ) -var knownUndocumentedConstants = map[string]string{ - "MsgTypeChatCommand": "chat_command", -} +var knownUndocumentedConstants = map[string]string{} // protocolSchemaEntry mirrors one element of the client_to_server / // server_to_client arrays in docs/protocol-schema.json. diff --git a/Server/ws/pubsub.go b/Server/ws/pubsub.go index 840dc1dc..3c4078b8 100644 --- a/Server/ws/pubsub.go +++ b/Server/ws/pubsub.go @@ -84,6 +84,16 @@ func (ps *PubSub) Subscribe(client *Client, topic Topic) { ps.mu.Lock() defer ps.mu.Unlock() + // A dying connection must not (re-)take a topic: its replacement's own + // unsubscribes would skip the entry (unsubscribeLocked's identity guard) + // and publishes would go to the closed connection. registerNow closes the + // old client's send BEFORE stripping it under this same lock, so a late + // Subscribe either sees sendClosed here and is refused, or slipped in + // earlier and is removed by the subsequent UnsubscribeAll. + if client.isSendClosed() { + return + } + // Forward index subs, ok := ps.topics[topic] if !ok { @@ -106,51 +116,50 @@ func (ps *PubSub) Subscribe(client *Client, topic Topic) { func (ps *PubSub) Unsubscribe(client *Client, topic Topic) { ps.mu.Lock() defer ps.mu.Unlock() - ps.unsubscribeLocked(client.userID, topic) + ps.unsubscribeLocked(client, topic) } -// unsubscribeLocked removes userID from topic. Caller must hold ps.mu (write). -func (ps *PubSub) unsubscribeLocked(userID int64, topic Topic) { +// unsubscribeLocked removes client from topic. Caller must hold ps.mu (write). +// +// Both indexes are keyed by userID, but a reconnect registers a *new* *Client +// under that same userID. Unsubscribing a client that has already been replaced +// must be a no-op: the replacement stays in h.clients and keeps answering +// ping/pong, so if its subscriptions were stripped it would never reconnect — +// it would just silently stop receiving every broadcast. +func (ps *PubSub) unsubscribeLocked(client *Client, topic Topic) { // Forward index if subs, ok := ps.topics[topic]; ok { - delete(subs, userID) + if cur, ok := subs[client.userID]; ok && cur != client { + return // replaced by a newer connection; leave it alone + } + delete(subs, client.userID) if len(subs) == 0 { delete(ps.topics, topic) } } // Reverse index - if ts, ok := ps.clients[userID]; ok { + if ts, ok := ps.clients[client.userID]; ok { delete(ts, topic) if len(ts) == 0 { - delete(ps.clients, userID) + delete(ps.clients, client.userID) } } } // UnsubscribeAll removes client from every topic it is subscribed to. // Called when a client disconnects. +// +// Topics already taken over by a newer connection for the same user are left +// in place — see unsubscribeLocked. Deleting a key during range is defined, and +// unsubscribeLocked drops the reverse-index entry once the last topic goes. func (ps *PubSub) UnsubscribeAll(client *Client) { ps.mu.Lock() defer ps.mu.Unlock() - ts, ok := ps.clients[client.userID] - if !ok { - return + for topic := range ps.clients[client.userID] { + ps.unsubscribeLocked(client, topic) } - - // Remove from every topic's subscriber set. - for topic := range ts { - if subs, ok := ps.topics[topic]; ok { - delete(subs, client.userID) - if len(subs) == 0 { - delete(ps.topics, topic) - } - } - } - - // Remove the reverse-index entry entirely. - delete(ps.clients, client.userID) } // Priority levels for pub/sub delivery. diff --git a/Server/ws/pubsub_test.go b/Server/ws/pubsub_test.go index ecd46d00..ed4fcd24 100644 --- a/Server/ws/pubsub_test.go +++ b/Server/ws/pubsub_test.go @@ -98,6 +98,59 @@ func TestPubSub_UnsubscribeAllEmpty(t *testing.T) { ps.UnsubscribeAll(c) } +// A reconnect registers a new *Client under the same userID. A kick of the +// replaced connection (sweepRevokedSessions, sweepStaleClients, the handlers.go +// kicks) must not strip the live connection's subscriptions: it stays in +// h.clients and keeps passing ping/pong, so it never reconnects — it just +// silently stops receiving every broadcast. +func TestPubSub_UnsubscribeAllKeepsReplacementClientSubscribed(t *testing.T) { + ps := newTestPubSub() + stale := makeTestClient(1) + live := makeTestClient(1) + + ps.Subscribe(stale, TopicGlobal) + ps.Subscribe(stale, "channel:5") + + // Reconnect: the new client re-subscribes, replacing the forward-index + // entries for the topics it shares with the old connection. + ps.Subscribe(live, TopicGlobal) + ps.Subscribe(live, "channel:7") + + ps.UnsubscribeAll(stale) + + if n := ps.SubscriberCount(TopicGlobal); n != 1 { + t.Errorf("global SubscriberCount = %d after kicking replaced client, want 1", n) + } + if n := ps.SubscriberCount("channel:7"); n != 1 { + t.Errorf("channel:7 SubscriberCount = %d, want 1", n) + } + // channel:5 was only ever the stale connection's, so it should be gone. + if n := ps.SubscriberCount("channel:5"); n != 0 { + t.Errorf("channel:5 SubscriberCount = %d, want 0 (stale client's own topic)", n) + } + if topics := ps.TopicsForClient(1); len(topics) != 2 { + t.Errorf("TopicsForClient = %v, want the live client's 2 topics", topics) + } +} + +// Same hazard on the single-topic path: voice_leave.go and the +// revokeUnreadableChannels sweep both call Unsubscribe with a *Client that may +// already have been replaced. +func TestPubSub_UnsubscribeKeepsReplacementClientSubscribed(t *testing.T) { + ps := newTestPubSub() + stale := makeTestClient(1) + live := makeTestClient(1) + + ps.Subscribe(stale, "voice:7") + ps.Subscribe(live, "voice:7") + + ps.Unsubscribe(stale, "voice:7") + + if n := ps.SubscriberCount("voice:7"); n != 1 { + t.Errorf("voice:7 SubscriberCount = %d after unsubscribing replaced client, want 1", n) + } +} + // ─── Publish ───────────────────────────────────────────────────────────────── func TestPubSub_Publish(t *testing.T) { diff --git a/Server/ws/reconnect_active_channel_test.go b/Server/ws/reconnect_active_channel_test.go new file mode 100644 index 00000000..3fa1889a --- /dev/null +++ b/Server/ws/reconnect_active_channel_test.go @@ -0,0 +1,244 @@ +package ws + +// reconnect_active_channel_test.go — regression test for finding v019. +// +// registerNow restores a resuming client's ChannelTopic subscription by +// copying it from the OLD client entry. But readPump's unregister deletes that +// entry as soon as the server observes the socket close, which normally +// happens well before the client's first reconnect attempt — so on the common +// resume there is nothing to copy from, and the reconnected socket holds NO +// channel subscription until its post-auth_ok channel_focus round trip lands. +// Every message broadcast to that channel in the window (auth_ok write, up to +// maxColdReplay replay frames, pump startup, one RTT) is delivered to nobody +// and can never be re-requested, because the client only reports max(seq). +// +// The auth frame now carries active_channel_id, which handleReconnect promotes +// to c.channelID — READ-gated — before registerNow runs. + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" +) + +func TestReconnect_AuthFrameActiveChannelRestoresSubscription(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "resume-focus-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByID(ctx, userID) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + + chID, err := database.CreateChannel(ctx, "resume-room", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + hub := NewHub(database, auth.NewRateLimiter(), nil) + go hub.Run() + defer hub.Stop() + + // Precondition: the channel IS readable, so the auth-frame id is eligible + // to be honoured (an unreadable one must be ignored — covered below). + allowed, err := hub.computeAllowedChannels(ctx, database, user) + if err != nil { + t.Fatalf("computeAllowedChannels: %v", err) + } + if !allowed[chID] { + t.Fatalf("precondition: channel %d should be READ-visible", chID) + } + + // Deliberately NO pre-registered old client: this is the exact case the + // subscription-transfer path cannot cover, because there is nothing to + // copy the focused channel from. + // EventsSinceFiltered returns nil unless last_seq is STRICTLY greater than + // the oldest buffered seq and no greater than the newest, so the window has + // to bracket it: 98 anchors below, 100 sits above. Without that the replay + // is empty, handleReconnect returns false, and the connection falls through + // to the full-ready path -- which also sends auth_ok, so asserting on that + // frame alone would not distinguish the two. + rb := hub.ReplayBuffer() + rb.Push(98, chID, []byte(`{"seq":98,"type":"chat_message","payload":{}}`)) + rb.Push(99, chID, []byte(`{"seq":99,"type":"chat_message","payload":{}}`)) + rb.Push(100, chID, []byte(`{"seq":100,"type":"chat_message","payload":{}}`)) + + srv := httptest.NewServer(ServeWS(hub, database, []string{"*"})) + defer srv.Close() + + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + raw, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": uint64(99), + "active_channel_id": chID, + }, + }) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second) + defer readCancel() + _, msg, err := conn.Read(readCtx) + if err != nil { + t.Fatalf("read handshake response: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(msg, &parsed); err != nil { + t.Fatalf("unmarshal handshake response: %v", err) + } + if parsed["type"] != MsgTypeAuthOK { + t.Fatalf("expected auth_ok (buffer-tier resume), got %v", parsed["type"]) + } + + // The registered client must already be focused on — and subscribed to — + // the channel, with no channel_focus ever sent. + deadline := time.Now().Add(2 * time.Second) + for { + hub.mu.Lock() + c := hub.clients[userID] + hub.mu.Unlock() + if c != nil && c.getChannelID() == chID { + break + } + if time.Now().After(deadline) { + got := int64(-1) + if c != nil { + got = c.getChannelID() + } + t.Fatalf("resumed client channelID = %d, want %d — the socket is unsubscribed until channel_focus, "+ + "so every broadcast to that channel in the meantime is lost", got, chID) + } + time.Sleep(10 * time.Millisecond) + } +} + +// The id is attacker-controlled, so a channel the user may not read must never +// be honoured — it would hand out a ChannelTopic subscription (and with it the +// channel's live message stream) that the permission set denies. +func TestReconnect_AuthFrameActiveChannelIsReadGated(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "resume-gated-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + noPerms, err := database.CreateRole(ctx, "no-perms-resume", nil, 0, 1) + if err != nil || noPerms == nil { + t.Fatalf("CreateRole: %v", err) + } + if err := database.UpdateUserRole(ctx, userID, noPerms.ID); err != nil { + t.Fatalf("UpdateUserRole: %v", err) + } + user, err := database.GetUserByID(ctx, userID) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + + secretID, err := database.CreateChannel(ctx, "secret-resume", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + hub := NewHub(database, auth.NewRateLimiter(), nil) + go hub.Run() + defer hub.Stop() + + allowed, err := hub.computeAllowedChannels(ctx, database, user) + if err != nil { + t.Fatalf("computeAllowedChannels: %v", err) + } + if allowed[secretID] { + t.Fatalf("precondition: channel %d must NOT be readable by this role", secretID) + } + + // Global (channel_id 0) frames bracketing last_seq, so the resume takes the + // buffer tier rather than falling through to full ready. + hub.ReplayBuffer().Push(98, 0, []byte(`{"seq":98,"type":"presence","payload":{}}`)) + hub.ReplayBuffer().Push(99, 0, []byte(`{"seq":99,"type":"presence","payload":{}}`)) + hub.ReplayBuffer().Push(100, 0, []byte(`{"seq":100,"type":"presence","payload":{}}`)) + + srv := httptest.NewServer(ServeWS(hub, database, []string{"*"})) + defer srv.Close() + + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + raw, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": uint64(99), + "active_channel_id": secretID, + }, + }) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second) + defer readCancel() + if _, _, err := conn.Read(readCtx); err != nil { + t.Fatalf("read handshake response: %v", err) + } + + // Give registration a moment, then assert the claim was refused. + time.Sleep(200 * time.Millisecond) + hub.mu.Lock() + c := hub.clients[userID] + hub.mu.Unlock() + if c == nil { + t.Fatal("client was not registered") + } + if got := c.getChannelID(); got == secretID { + t.Fatalf("client was focused on unreadable channel %d from an attacker-supplied auth frame", got) + } +} diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go index a5456d5c..2254535c 100644 --- a/Server/ws/reconnect_db_test.go +++ b/Server/ws/reconnect_db_test.go @@ -161,3 +161,217 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { t.Fatalf("expected db tier count=1, got %d", dbTier) } } + +// TestReconnect_ColdTierAtRowLimit_ForcesFullReady locks the truncation guard. +// +// GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a gap larger than +// the cap returns the OLDEST n rows and silently drops the newest. Replaying +// that as a successful resume looks complete to the client — it tracks only +// max(seq) and cannot detect the hole — so the dropped range is lost until some +// later full resync. State events (channel/role/member changes) in that range +// are never repaired by REST history fetches. +// +// Setup mirrors TestReconnect_BufferMiss_FallsBackToDBTier, but seeds 100 more +// events than the cap so the query comes back exactly full. +func TestReconnect_ColdTierAtRowLimit_ForcesFullReady(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + + userID, err := database.CreateUser(context.Background(), "reconnect-overflow-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Seed seqs 501..(500+cap+100): the query caps at `cap` rows, so the newest + // 100 are dropped — the exact overflow condition. + eventStore := openEventStoreDB(t) + bgCtx := context.Background() + const overflow = 100 + events := make([]db.PersistedEvent, 0, ws.MaxColdReplayForTest+overflow) + for i := range ws.MaxColdReplayForTest + overflow { + seq := int64(501 + i) + events = append(events, db.PersistedEvent{ + Seq: seq, + EventType: "broadcast", + ChannelID: 0, + Payload: fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq), + }) + } + if n, err := eventStore.PersistEvents(bgCtx, events); err != nil || n != len(events) { + t.Fatalf("PersistEvents: persisted %d/%d, err=%v", n, len(events), err) + } + + hub := ws.NewHub(database, limiter, nil) + hub.SetEventStore(eventStore) + go hub.Run() + defer hub.Stop() + + // Ring buffer holds 501..1500, so last_seq=500 misses it and the cold tier + // is consulted. + rb := hub.ReplayBuffer() + dummyPayload := []byte(`{"type":"broadcast"}`) + for seq := uint64(501); seq <= 1500; seq++ { + rb.Push(seq, 0, dummyPayload) + } + if oldest := rb.OldestSeq(); oldest != 501 { + t.Fatalf("pre-condition: expected oldestSeq=501, got %d", oldest) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + dialCtx, cancel := context.WithTimeout(bgCtx, 30*time.Second) + defer cancel() + + conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": uint64(500), + }, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + // Reading the first frame guarantees the handshake picked a tier. + if _, _, err := conn.Read(dialCtx); err != nil { + t.Fatalf("read handshake response: %v", err) + } + + _, dbTier, fullTier := hub.ReconnectTierStats() + if dbTier != 0 { + t.Errorf("db tier count = %d, want 0: a truncated cold-tier replay was delivered as a complete resume", dbTier) + } + if fullTier != 1 { + t.Errorf("full tier count = %d, want 1: an over-cap gap must force a full ready re-sync", fullTier) + } +} + +// TestReconnect_ColdTierMergesRingBufferTail locks the flush-lag hole: the +// EventPersister flushes asynchronously (~100ms batches), so cold rows can lag +// the live seq. Events broadcast after the last flush sit only in the ring +// buffer — a cold replay built from persisted rows alone presents an +// incomplete resume as complete (the client tracks only max(seq) and cannot +// detect the hole), which the maxColdReplay cap in the same function exists +// to prevent. +// +// Setup: cold rows 501..700; ring buffer holds 601..710 (so the buffer cannot +// serve last_seq=500 itself, but can serve everything past the newest +// persisted row). The replay must deliver 501..710 — the 200 cold rows plus +// the 10-event buffer tail. +func TestReconnect_ColdTierMergesRingBufferTail(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + + userID, err := database.CreateUser(context.Background(), "reconnect-tail-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + eventStore := openEventStoreDB(t) + bgCtx := context.Background() + for seq := int64(501); seq <= 700; seq++ { + payload := fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq) + if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil { + t.Fatalf("PersistEvent seq=%d: %v", seq, err) + } + } + + hub := ws.NewHub(database, limiter, nil) + hub.SetEventStore(eventStore) + go hub.Run() + defer hub.Stop() + + rb := hub.ReplayBuffer() + for seq := uint64(601); seq <= 710; seq++ { + rb.Push(seq, 0, fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq)) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + dialCtx, cancel := context.WithTimeout(bgCtx, 15*time.Second) + defer cancel() + + conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]any{"token": token, "last_seq": uint64(500)}, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + _, msg, err := conn.Read(dialCtx) + if err != nil { + t.Fatalf("read auth_ok: %v", err) + } + var resp map[string]any + _ = json.Unmarshal(msg, &resp) + if resp["type"] != "auth_ok" { + t.Fatalf("expected auth_ok, got %s", msg) + } + + // Count replayed frames; the highest seq seen must reach the buffer tail. + var replayed int + var maxSeq float64 + for { + readCtx, readCancel := context.WithTimeout(dialCtx, 500*time.Millisecond) + _, evt, readErr := conn.Read(readCtx) + readCancel() + if readErr != nil { + break + } + var frame map[string]any + if json.Unmarshal(evt, &frame) == nil && frame["type"] == "broadcast" { + replayed++ + if s, ok := frame["seq"].(float64); ok && s > maxSeq { + maxSeq = s + } + } + } + if replayed != 210 { + t.Errorf("replayed %d events, want 210 (200 cold rows + 10 ring-buffer tail)", replayed) + } + if maxSeq != 710 { + t.Errorf("max replayed seq = %.0f, want 710 — events after the last persister flush were silently dropped", maxSeq) + } +} diff --git a/Server/ws/reconnect_pruned_prefix_test.go b/Server/ws/reconnect_pruned_prefix_test.go new file mode 100644 index 00000000..41b80c5e --- /dev/null +++ b/Server/ws/reconnect_pruned_prefix_test.go @@ -0,0 +1,138 @@ +package ws_test + +// reconnect_pruned_prefix_test.go — regression test for the retention-pruning +// gap (finding v000): PruneEventsOlderThan deletes cold-tier rows purely by +// created_at with no seq-floor coordination, so a client whose last_seq +// predates the retention cutoff can see a channel-filtered cold-tier query +// return a non-empty *suffix* of rows that starts well after last_seq+1. +// handleReconnect must detect that gap with an unfiltered oldest-seq probe +// and force a full ready re-sync rather than replaying the surviving suffix +// as if it were a complete resume. + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/ws" +) + +// TestReconnect_PrunedPrefix_ForcesFullReady locks the guard added in +// handleReconnect's cold-tier branch: when retention pruning has deleted the +// events immediately after last_seq, the surviving suffix returned by +// GetEventsSinceForChannels must NOT be accepted as a complete resume. +// +// Setup mirrors TestReconnect_BufferMiss_FallsBackToDBTier, but simulates a +// pruned prefix: only seqs 1000..1100 are persisted (as if 1..999 were +// pruned by PruneEventsOlderThan), and the ring buffer only covers the same +// range (as if it, too, has rolled past the pruned events). A client +// reconnecting with last_seq=500 has a store that is unfiltered-non-empty but +// whose oldest surviving seq (1000) is far past last_seq+1 (501) — the exact +// condition the oldest-seq probe (es.GetEventsSince(ctx, 0, 1)) must catch. +func TestReconnect_PrunedPrefix_ForcesFullReady(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + + userID, err := database.CreateUser(context.Background(), "reconnect-pruned-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Only seqs 1000..1100 survive in the cold-tier store — everything from + // 1..999 has already been pruned by PruneEventsOlderThan, which deletes by + // created_at with no seq-floor coordination. + eventStore := openEventStoreDB(t) + bgCtx := context.Background() + for seq := int64(1000); seq <= 1100; seq++ { + payload := fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq) + if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil { + t.Fatalf("PersistEvent seq=%d: %v", seq, err) + } + } + + hub := ws.NewHub(database, limiter, nil) + hub.SetEventStore(eventStore) + go hub.Run() + defer hub.Stop() + + // The ring buffer has also rolled past the pruned range: only 1000..1100 + // are present, so a client at last_seq=500 misses the buffer entirely and + // the cold tier is consulted. + rb := hub.ReplayBuffer() + dummyPayload := []byte(`{"type":"broadcast"}`) + for seq := uint64(1000); seq <= 1100; seq++ { + rb.Push(seq, 0, dummyPayload) + } + if oldest := rb.OldestSeq(); oldest != 1000 { + t.Fatalf("pre-condition: expected oldestSeq=1000, got %d", oldest) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + dialCtx, cancel := context.WithTimeout(bgCtx, 10*time.Second) + defer cancel() + + conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]any{ + "token": token, + "last_seq": uint64(500), + }, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + // The first message back must be the full ready payload (type "ready"), + // NOT auth_ok with replay_source="db" — accepting the surviving suffix as + // a complete resume would silently skip every event between last_seq and + // the pruning cutoff. + _, msg, err := conn.Read(dialCtx) + if err != nil { + t.Fatalf("read handshake response: %v", err) + } + var resp map[string]any + if err := json.Unmarshal(msg, &resp); err != nil { + t.Fatalf("unmarshal response: %v; raw=%s", err, msg) + } + if resp["type"] == "auth_ok" { + if payloadField, _ := resp["payload"].(map[string]any); payloadField["replay_source"] == "db" { + t.Fatalf("reconnect accepted a pruned-prefix suffix as a complete db-tier resume: %s", msg) + } + } + + _, dbTier, fullTier := hub.ReconnectTierStats() + if dbTier != 0 { + t.Errorf("db tier count = %d, want 0: a suffix left behind by retention pruning was delivered as a complete resume", dbTier) + } + if fullTier != 1 { + t.Errorf("full tier count = %d, want 1: a gap before last_seq left by retention pruning must force a full ready re-sync", fullTier) + } +} diff --git a/Server/ws/reconnect_voice_supplement_test.go b/Server/ws/reconnect_voice_supplement_test.go new file mode 100644 index 00000000..e15ee892 --- /dev/null +++ b/Server/ws/reconnect_voice_supplement_test.go @@ -0,0 +1,162 @@ +package ws + +// reconnect_voice_supplement_test.go — regression test for finding v107. +// +// Voice membership is gated on CONNECT_VOICE alone (voice_join.go), and the +// live fan-out path deliberately unions the READ audience with the room's +// current participants for exactly that reason (broadcastVoiceEvent). Replay, +// though, filtered purely on computeAllowedChannels — READ-visible channels +// plus open DMs — so a resuming participant silently missed the buffered +// voice_state/voice_leave for the very room they were still in, including a +// key holder's departure, which is the E2EE stall the live-path union exists +// to prevent. +// +// The supplement must not widen what the client can read: the room stays out +// of allowedChannelIDs (that map also gates the ChannelTopic subscription in +// registerNow), so this test asserts the room's chat frames are still filtered +// out while its voice frames come through. + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" +) + +func TestReconnect_ReplaysOwnVoiceRoomOutsideReadableChannels(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "voice-resume-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + // A role with no permissions at all: VisibleChannelIDs returns nothing, so + // the voice room the user is live in cannot be in allowedChannelIDs. + noPerms, err := database.CreateRole(ctx, "voice-only", nil, 0, 1) + if err != nil || noPerms == nil { + t.Fatalf("CreateRole: %v", err) + } + if err := database.UpdateUserRole(ctx, userID, noPerms.ID); err != nil { + t.Fatalf("UpdateUserRole: %v", err) + } + user, err := database.GetUserByID(ctx, userID) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + + vcID, err := database.CreateChannel(ctx, "vc-resume", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.JoinVoiceChannel(ctx, userID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + vs, err := database.GetVoiceState(ctx, userID) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState: %v", err) + } + + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + hub := NewHub(database, auth.NewRateLimiter(), nil) + go hub.Run() + defer hub.Stop() + + // Precondition: the room really is outside the READ-gated allowed set, so + // the plain replay filter can never deliver its events. + allowed, err := hub.computeAllowedChannels(ctx, database, user) + if err != nil { + t.Fatalf("computeAllowedChannels: %v", err) + } + if allowed[vcID] { + t.Fatalf("precondition: voice channel %d must not be READ-visible to the resuming user", vcID) + } + + // The still-registered previous connection is what carries the live voice + // session across the resume (registerNow transfers it when lastSeq > 0). + oldClient := NewTestClient(hub, userID, make(chan []byte, 64)) + oldClient.user = user + oldClient.setVoiceState(vcID, vs.JoinedAt) + hub.mu.Lock() + hub.clients[userID] = oldClient + hub.mu.Unlock() + + // Ring buffer: everything is scoped to the unreadable voice room. seq 99 + // only exists so last_seq=100 sits strictly inside the buffer window. + rb := hub.ReplayBuffer() + push := func(seq uint64, eventType string) { + rb.Push(seq, vcID, fmt.Appendf(nil, `{"seq":%d,"type":%q,"payload":{"channel_id":%d}}`, seq, eventType, vcID)) + } + push(99, MsgTypeChatMessage) + push(100, MsgTypeChatMessage) + push(101, MsgTypeChatMessage) // must stay filtered out — no READ on this channel + push(102, MsgTypeVoiceState) + push(103, MsgTypeVoiceLeaveBC) + + srv := httptest.NewServer(ServeWS(hub, database, []string{"*"})) + defer srv.Close() + + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if dialResp != nil && dialResp.Body != nil { + _ = dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + raw, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]any{"token": token, "last_seq": uint64(100)}, + }) + if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + readFrame := func(what string) map[string]any { + t.Helper() + readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second) + defer readCancel() + _, msg, err := conn.Read(readCtx) + if err != nil { + t.Fatalf("read %s: %v", what, err) + } + var parsed map[string]any + if err := json.Unmarshal(msg, &parsed); err != nil { + t.Fatalf("unmarshal %s: %v; raw=%s", what, err, msg) + } + return parsed + } + + if got := readFrame("handshake response")["type"]; got != MsgTypeAuthOK { + t.Fatalf("expected auth_ok (buffer-tier resume), got type=%v", got) + } + // Replay frames are written synchronously inside handleReconnect, before + // writePump starts, so they are the next two frames on the wire. + for i, want := range []string{MsgTypeVoiceState, MsgTypeVoiceLeaveBC} { + frame := readFrame("replay frame") + got, _ := frame["type"].(string) + if got == MsgTypeChatMessage { + t.Fatalf("replay frame %d leaked a chat frame for a channel the user cannot READ: %+v", i, frame) + } + if got != want { + t.Fatalf("replay frame %d: got type=%q, want %q — the resuming participant's own voice room was filtered out of replay", i, got, want) + } + } +} diff --git a/Server/ws/ringbuffer.go b/Server/ws/ringbuffer.go index f5e62a29..353a576d 100644 --- a/Server/ws/ringbuffer.go +++ b/Server/ws/ringbuffer.go @@ -58,6 +58,15 @@ func (rb *EventRingBuffer) EventsSince(afterSeq uint64) [][]byte { return nil } + // Likewise if the client claims events newer than anything we ever held: + // its counter and ours disagree (a restart can reseed seq below a client's + // remembered lastSeq), so an empty slice here would be read as "caught up" + // and freeze that client. afterSeq == newestSeq is the legitimate caught-up + // case and still returns an empty replay. + if afterSeq > rb.newestSeqLocked() { + return nil + } + result := make([][]byte, 0) for i := 0; i < rb.count; i++ { idx := (oldestIdx + i) % rb.size @@ -69,6 +78,12 @@ func (rb *EventRingBuffer) EventsSince(afterSeq uint64) [][]byte { return result } +// newestSeqLocked returns the highest sequence number in the buffer. Callers +// must hold rb.mu and must have checked rb.count > 0. +func (rb *EventRingBuffer) newestSeqLocked() uint64 { + return rb.entries[(rb.pos-1+rb.size)%rb.size].seq +} + // EventsSinceFiltered returns events with seq > afterSeq whose channelID is // in allowedChannelIDs or whose channelID is 0 (global broadcasts). // Returns nil if afterSeq is too old (same semantics as EventsSince). @@ -87,6 +102,12 @@ func (rb *EventRingBuffer) EventsSinceFiltered(afterSeq uint64, allowedChannelID return nil } + // See EventsSince: a client ahead of everything we ever buffered must get a + // full ready, not a silent "caught up". + if afterSeq > rb.newestSeqLocked() { + return nil + } + result := make([][]byte, 0) for i := 0; i < rb.count; i++ { idx := (oldestIdx + i) % rb.size diff --git a/Server/ws/ringbuffer_test.go b/Server/ws/ringbuffer_test.go index a3436385..51a32f0d 100644 --- a/Server/ws/ringbuffer_test.go +++ b/Server/ws/ringbuffer_test.go @@ -179,6 +179,34 @@ func TestEventsSince_AtLatestSeq(t *testing.T) { } } +// A client claiming a seq the buffer never held cannot be served a correct +// replay: nil is the "I can't guarantee coverage" signal that makes the caller +// fall through to the cold tier and a full ready. Returning an empty slice +// instead reads as "you are caught up" and silently freezes that client — +// reachable whenever the server's seq counter restarts below a client's +// remembered lastSeq (a restart with an empty event table does exactly that). +func TestEventsSince_AheadOfNewestSeq(t *testing.T) { + rb := ws.NewEventRingBuffer(8) + for i := uint64(1); i <= 5; i++ { + rb.Push(i, 0, []byte("x")) + } + + if got := rb.EventsSince(99); got != nil { + t.Errorf("EventsSince(99) = %v (len %d), want nil — client is ahead of the buffer", got, len(got)) + } + if got := rb.EventsSinceFiltered(99, map[int64]bool{1: true}); got != nil { + t.Errorf("EventsSinceFiltered(99) = %v (len %d), want nil — client is ahead of the buffer", got, len(got)) + } + + // The legitimate caught-up case must keep returning a non-nil empty replay. + if got := rb.EventsSince(5); got == nil { + t.Error("EventsSince(5) = nil, want an empty non-nil replay (caught up, not ahead)") + } + if got := rb.EventsSinceFiltered(5, map[int64]bool{1: true}); got == nil { + t.Error("EventsSinceFiltered(5) = nil, want an empty non-nil replay (caught up, not ahead)") + } +} + func TestEventsSince_WraparoundOrder(t *testing.T) { const cap = 4 rb := ws.NewEventRingBuffer(cap) diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 3dea17ef..dc2d1357 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strings" + "sync/atomic" "time" "github.com/coder/websocket" @@ -24,6 +25,11 @@ const ( // wsReadLimitBytes is the maximum size of a single inbound WebSocket // message. Must match the client-side upload cap. wsReadLimitBytes = config.MaxMessageBytes + + // maxColdReplay caps how many persisted events a single cold-tier reconnect + // replay may return. A gap that reaches the cap cannot be replayed correctly + // and falls back to a full ready — see handleReconnect. + maxColdReplay = 5000 ) // ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth, @@ -83,15 +89,18 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun func (h *Hub) upgradeAndAuth( conn *websocket.Conn, database *db.DB, r *http.Request, ) (*Client, uint64, error) { - user, tokenHash, lastSeq, err := authenticateConn(r.Context(), conn, database) + user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database) if err != nil { slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr) _ = conn.Close(websocket.StatusPolicyViolation, "authentication failed") return nil, 0, err } + lastSeq := hint.LastSeq c := newClient(h, conn, user, tokenHash, lastSeq, r.Context()) c.remoteAddr = r.RemoteAddr + // Untrusted until handleReconnect checks it against the allowed set. + c.authChannelID = hint.ChannelID // Look up role name for protocol-compliant payloads and cache on client. roleName := "member" @@ -129,9 +138,30 @@ func (h *Hub) handleReconnect( return false } - events := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs) - replaySource := "buffer" - if events == nil { + // Voice membership needs only CONNECT_VOICE, not READ_MESSAGES + // (voice_join.go), so a live participant resuming can have their own room + // excluded from allowedChannelIDs entirely — most commonly a DM voice call + // after the DM was closed (computeAllowedChannels sources DM IDs from + // dm_open_state). Capture it before registerNow performs the same + // lookup/transfer, so replay can be supplemented below with the room's own + // voice_state/voice_leave even though the room is outside the READ-gated + // allowed set. It is never added to allowedChannelIDs itself — that map + // also gates the ChannelTopic subscription in registerNow and would leak + // the channel's chat to a user who cannot read it. + var liveVoiceChID int64 + if old := h.GetClient(c.userID); old != nil { + liveVoiceChID = old.getVoiceChID() + } + + var ( + events [][]byte + replaySource = "buffer" + persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below + maxPersistedSeq uint64 + ) + if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil { + events = buf + } else { // Phase B Step 7 — try cold-tier replay from the EventStore before // giving up and forcing a full ready re-sync. if esp := h.eventStore.Load(); esp != nil { @@ -140,17 +170,72 @@ func (h *Hub) handleReconnect( for cid := range allowedChannelIDs { channelIDs = append(channelIDs, cid) } - const maxColdReplay = 5000 persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, maxColdReplay) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64 - if dbErr != nil { + switch { + case dbErr != nil: slog.Warn("ws handleReconnect: cold-tier replay query failed", "user_id", c.userID, "err", dbErr) - } else if len(persisted) > 0 { - events = make([][]byte, 0, len(persisted)) - for _, p := range persisted { - events = append(events, p.Payload) + case len(persisted) >= maxColdReplay: + // The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full + // result means the gap exceeds the cap and the NEWEST events were + // dropped. Replaying it would look like a complete resume to the + // client — it tracks only max(seq) and cannot detect the hole — + // silently losing state events that REST history never repairs. + // Leave events nil so the fall-through forces a full ready. + slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready", + "user_id", c.userID, "last_seq", lastSeq, "cap", maxColdReplay) + case len(persisted) > 0: + // Retention pruning (PruneEventsOlderThan) deletes purely by + // created_at with no seq-floor coordination, so this + // channel-filtered result can be a surviving suffix left behind + // after the events between lastSeq and persisted[0] were + // pruned. Accepting it as-is would present a hole as a complete + // resume, since the client tracks only max(seq). Probe the + // store's oldest surviving seq UNFILTERED before trusting it — + // a channel-filtered contiguity check on persisted itself can't + // work, since a sparse per-channel result is legitimately + // non-contiguous. + oldest, oldestErr := es.GetEventsSince(ctx, 0, 1) + switch { + case oldestErr != nil: + slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready", + "user_id", c.userID, "err", oldestErr) + case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64 + var oldestSeq int64 + if len(oldest) > 0 { + oldestSeq = oldest[0].Seq + } + slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready", + "user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq) + default: + persistedTail = make([][]byte, 0, len(persisted)) + for _, p := range persisted { + persistedTail = append(persistedTail, p.Payload) + } + // The EventPersister flushes asynchronously, so cold rows can + // lag the live seq: events broadcast after the last flush sit + // only in the ring buffer. Confirm the buffer can cover + // everything above the newest persisted row — the + // authoritative re-read happens atomically with registerNow + // below, but a hole here must still force a full ready + // rather than a replay with a silent gap at its end. + maxPersistedSeq = uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64 + switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); { + case tail != nil: + case atomic.LoadUint64(&h.seq) == maxPersistedSeq: + // Post-restart empty buffer with the hub seq seeded from + // the store max: nothing was broadcast after the last + // persisted row, so the cold rows alone are complete. + default: + slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready", + "user_id", c.userID, "max_persisted_seq", maxPersistedSeq) + persistedTail = nil + } + if persistedTail != nil { + events = persistedTail + replaySource = "db" + } } - replaySource = "db" } } if events == nil { @@ -159,6 +244,86 @@ func (h *Hub) handleReconnect( return false } } + + // Register BEFORE writing replay data so broadcasts that arrive during + // the write window are queued in the client's send buffer instead of + // being lost (BUG-123). writePump hasn't started yet, so queued messages + // will be drained once the pumps begin. + // + // The replay set built above can go stale between being read and c + // becoming reachable: deliverBroadcast (the hub's own Run goroutine) + // allocates a seq, pushes it to the ring buffer, and publishes to current + // subscribers — all under h.seqMu — concurrently with this handshake + // goroutine. registerNow is what subscribes this connection, so a + // broadcast landing in the gap between the snapshot above and + // registration reaches nobody, and the client's max(seq)-only tracking + // means it can never be requested again once a later frame arrives. + // Close the window by re-reading the ring-buffer-derived portion of + // `events` and calling registerNow inside the SAME h.seqMu critical + // section deliverBroadcast uses, so no seq can be allocated in between. + // Restore the client's channel subscription BEFORE registration. + // + // registerNow copies the channel subscription from the OLD client entry, + // but on a resume where the server already observed the previous socket + // close there is no old entry to copy from — so without this the resumed + // connection holds no ChannelTopic subscription until its post-auth_ok + // channel_focus round trip completes. Everything broadcast to that channel + // in the window (auth_ok write + up to maxColdReplay replay frames + pump + // startup + one RTT) is delivered to nobody on this socket, and the client + // can never ask for it back because it only ever reports max(seq). + // + // Set outside the h.seqMu section below so this does not introduce a + // seqMu -> c.mu lock-order edge that nothing else in the hub has. + // + // c.authChannelID is attacker-controlled, so it is honoured only when the + // freshly computed read-permission set contains it. Fail closed: an + // unknown or now-unreadable id leaves channelID at 0, which is exactly the + // pre-existing behaviour rather than a new denial. + if c.authChannelID != 0 { + if allowedChannelIDs[c.authChannelID] { + c.mu.Lock() + c.channelID = c.authChannelID + c.mu.Unlock() + } else { + slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame", + "user_id", c.userID, "channel_id", c.authChannelID) + } + } + + h.seqMu.Lock() + switch replaySource { + case "buffer": + fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs) + if fresh == nil { + // The buffer window closed between the earlier check and this + // lock (an extreme write burst evicted lastSeq) — there is + // nothing left to fall back to for this attempt but a full ready. + h.seqMu.Unlock() + slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready", + "user_id", c.userID, "last_seq", lastSeq) + h.reconnectTierFull.Add(1) + telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full")) + return false + } + events = fresh + case "db": + switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); { + case tail != nil: + events = append(append([][]byte{}, persistedTail...), tail...) + case atomic.LoadUint64(&h.seq) == maxPersistedSeq: + events = persistedTail + default: + h.seqMu.Unlock() + slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready", + "user_id", c.userID, "max_persisted_seq", maxPersistedSeq) + h.reconnectTierFull.Add(1) + telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full")) + return false + } + } + h.registerNow(c, allowedChannelIDs) + h.seqMu.Unlock() + switch replaySource { case "buffer": h.reconnectTierBuf.Add(1) @@ -167,11 +332,15 @@ func (h *Hub) handleReconnect( } telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource)) - // Register BEFORE writing replay data so broadcasts that arrive during - // the write window are queued in the client's send buffer instead of - // being lost (BUG-123). writePump hasn't started yet, so queued messages - // will be drained once the pumps begin. - h.registerNow(c, allowedChannelIDs) + // Best-effort supplement: the user's own live voice room may sit outside + // allowedChannelIDs (see the capture of liveVoiceChID above), so its + // voice_state/voice_leave would otherwise never reach this replay at all. + // Tries the ring buffer first, then the cold-tier store; a miss on both + // just leaves this one supplement as a no-op, not a regression versus the + // pre-fix behaviour. + if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] { + events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...) + } // Replay succeeded — send auth_ok then missed events. The replay tier // is included in the payload so the client can attribute reconnect @@ -179,14 +348,14 @@ func (h *Hub) handleReconnect( slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource) if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil { slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err) - h.unregisterNow(c) + h.unregisterFailedHandshake(ctx, c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return true } for _, evt := range events { if err := conn.Write(ctx, websocket.MessageText, evt); err != nil { slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err) - h.unregisterNow(c) + h.unregisterFailedHandshake(ctx, c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return true } @@ -200,6 +369,77 @@ func (h *Hub) handleReconnect( return true } +// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or +// after afterSeq, bypassing the READ-gated channel filter entirely. Voice +// membership needs only CONNECT_VOICE (voice_join.go), so a resuming +// participant's own room is not always in their READ-visible set — a stock +// example is a DM voice call after the DM was closed. Tries the ring buffer +// first (fresh, so it observes anything pushed concurrently with the caller), +// then falls back to the cold-tier store; returns nil, not an error, on a +// miss in both, since this is a best-effort supplement to the main replay. +func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte { + if chID == 0 { + return nil + } + only := map[int64]bool{chID: true} + var raw [][]byte + if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil { + raw = buf + } else if esp := h.eventStore.Load(); esp != nil { + es := *esp + persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, maxColdReplay) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64 + if err != nil { + return nil + } + raw = make([][]byte, 0, len(persisted)) + for _, p := range persisted { + raw = append(raw, p.Payload) + } + } + if len(raw) == 0 { + return nil + } + filtered := make([][]byte, 0, len(raw)) + for _, evt := range raw { + switch extractEventType(evt) { + case MsgTypeVoiceState, MsgTypeVoiceLeaveBC: + filtered = append(filtered, evt) + } + } + return filtered +} + +// unregisterFailedHandshake removes c after a post-registerNow handshake +// write failure. No readPump ever starts for this connection, and the old +// connection this one replaced already ran its defer (skipping teardown +// because this client held the slot) — so when no replacement remains, the +// standard disconnect teardown must run here or the user stays online forever. +func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) { + // Snapshot voice state BEFORE unregister, mirroring readPump's defer + // (serve_pumps.go): once unregisterNow removes c, there is no way to tell + // whether it still owned a (possibly just-transferred) voice session. + voiceChID := c.getVoiceChID() + replaced := h.unregisterNow(c) + if !replaced { + cleanupCtx := context.WithoutCancel(ctx) + // A connection that inherited a transferred voice session (the + // replay-failure fallback in handleFreshConnect deliberately keeps + // the voice_states row and registerNow transfers it onto c) must have + // that session torn down here too, or the row, the LiveKit + // participant, and a stale E2EE key-holder entry all survive this + // connection's death until the next sweep (up to 60s). + if voiceChID != 0 { + h.handleVoiceLeave(cleanupCtx, c) + } + _ = h.db.MarkUserDisconnected(cleanupCtx, c.userID) + // custom_status is nil, not c.user.CustomStatus: see the identical + // note in serve_pumps.go's readPump defer — that field is an + // auth-time snapshot, never updated, so broadcasting it here can + // resurrect a status the user already changed or cleared. + h.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, nil)) + } +} + // applyConnectStatus writes the status this session comes online as and caches // it on the client. // @@ -259,14 +499,15 @@ func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user // Include the user's open DM channels. Only the ID set matters here, so // use the PK-covered dm_open_state lookup instead of the full DM query. + // Fatal like the three sibling lookups above: a silently DM-stripped + // replay advances the client's lastSeq past DM events it never received — + // a permanent hole. The caller's error path falls back to full ready. dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID) if dmErr != nil { - slog.Warn("computeAllowedChannels GetUserDMChannelIDs", "err", dmErr) - // Non-fatal: DM events will simply be filtered out. - } else { - for _, id := range dmIDs { - allowed[id] = true - } + return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr) + } + for _, id := range dmIDs { + allowed[id] = true } return allowed, nil @@ -280,32 +521,46 @@ func (h *Hub) handleFreshConnect( // session must be removed so the ready payload doesn't include it and // other clients see a voice_leave broadcast. if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil { - slog.Info("ws fresh connect: cleaning stale voice state", - "user_id", c.userID, "channel_id", vs.ChannelID) - if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { - slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr) - } - h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceLeave(vs.ChannelID, c.userID)) - if h.livekit != nil { - // BUG-089: Capture stale join token so the goroutine only removes - // the exact stale participant. The identity includes joinedAt, so - // even if the user rejoins voice quickly, the new session has a - // different identity and won't be removed. The removal must - // complete even if this connection drops mid-handshake, so detach - // from cancellation (values kept); shutdown is handled via h.stop. - staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt - lkCtx := context.WithoutCancel(ctx) - go func() { - select { - case <-h.stop: - return - default: - } - if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil { - slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)", - "err", err, "user_id", staleUserID, "channel_id", staleChID) - } - }() + // Replay-failure fallback (lastSeq > 0): registerNow below transfers + // the still-registered old connection's live voice state into this + // client. Deleting the DB row here — and the LiveKit participant, + // whose removal token is the very JoinedAt being transferred — would + // leave the user "in voice" on the hub only: voice_join bounces off + // ALREADY_JOINED and sweepStaleVoiceStates never heals + // memory-without-row. Keep the row so ready stays consistent. If the + // old client unregisters before registerNow runs, the transfer is + // skipped and the next sweep reaps the then-truly-stale row. + if old := h.GetClient(c.userID); c.lastSeq > 0 && old != nil && old.getVoiceChID() == vs.ChannelID { + slog.Info("ws fresh connect: keeping voice state for replay-failure fallback", + "user_id", c.userID, "channel_id", vs.ChannelID) + } else { + slog.Info("ws fresh connect: cleaning stale voice state", + "user_id", c.userID, "channel_id", vs.ChannelID) + if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { + slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr) + } + h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceLeave(vs.ChannelID, c.userID)) + if h.livekit != nil { + // BUG-089: Capture stale join token so the goroutine only removes + // the exact stale participant. The identity includes joinedAt, so + // even if the user rejoins voice quickly, the new session has a + // different identity and won't be removed. The removal must + // complete even if this connection drops mid-handshake, so detach + // from cancellation (values kept); shutdown is handled via h.stop. + staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt + lkCtx := context.WithoutCancel(ctx) + go func() { + select { + case <-h.stop: + return + default: + } + if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil { + slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)", + "err", err, "user_id", staleUserID, "channel_id", staleChID) + } + }() + } } } @@ -348,7 +603,7 @@ func (h *Hub) handleFreshConnect( slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil { slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err) - h.unregisterNow(c) + h.unregisterFailedHandshake(ctx, c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return err } @@ -356,7 +611,7 @@ func (h *Hub) handleFreshConnect( slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready)) if err := conn.Write(ctx, websocket.MessageText, ready); err != nil { slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err) - h.unregisterNow(c) + h.unregisterFailedHandshake(ctx, c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return err } @@ -364,7 +619,7 @@ func (h *Hub) handleFreshConnect( slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr) _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "failed to build ready payload")) - h.unregisterNow(c) + h.unregisterFailedHandshake(ctx, c) _ = conn.Close(websocket.StatusInternalError, "failed to build ready payload") return readyErr } diff --git a/Server/ws/serve_auth.go b/Server/ws/serve_auth.go index b7e44e8b..28345347 100644 --- a/Server/ws/serve_auth.go +++ b/Server/ws/serve_auth.go @@ -14,32 +14,46 @@ import ( // authenticateConn reads the first WebSocket message and validates the session // token. Returns the authenticated user and the token hash (for later // periodic session revalidation). -func authenticateConn(parent context.Context, conn *websocket.Conn, database *db.DB) (*db.User, string, uint64, error) { +// resumeHint carries the client-supplied reconnect hints from the auth frame. +// Both fields are UNTRUSTED attacker-controlled input: LastSeq only ever +// narrows what replay will send, and ChannelID is checked against the allowed +// set before it is honoured (see handleReconnect). +type resumeHint struct { + LastSeq uint64 + ChannelID int64 +} + +func authenticateConn(parent context.Context, conn *websocket.Conn, database *db.DB) (*db.User, string, resumeHint, error) { ctx, cancel := context.WithTimeout(parent, authDeadline) defer cancel() _, raw, err := conn.Read(ctx) if err != nil { - return nil, "", 0, err + return nil, "", resumeHint{}, err } var env envelope if err := json.Unmarshal(raw, &env); err != nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid message")) - return nil, "", 0, fmt.Errorf("auth: invalid JSON: %w", err) + return nil, "", resumeHint{}, fmt.Errorf("auth: invalid JSON: %w", err) } - if env.Type != "auth" { + if env.Type != MsgTypeAuth { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth")) - return nil, "", 0, fmt.Errorf("auth: unexpected type %q", env.Type) + return nil, "", resumeHint{}, fmt.Errorf("auth: unexpected type %q", env.Type) } var p struct { Token string `json:"token"` LastSeq uint64 `json:"last_seq"` + // ActiveChannelID lets a resuming client re-declare the channel it had + // open, so the server can restore its ChannelTopic subscription during + // the handshake instead of leaving it unsubscribed until the + // post-auth_ok channel_focus round trip lands. + ActiveChannelID int64 `json:"active_channel_id"` } if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("missing token")) - return nil, "", 0, fmt.Errorf("auth: missing token") + return nil, "", resumeHint{}, fmt.Errorf("auth: missing token") } hash := auth.HashToken(p.Token) @@ -49,29 +63,29 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db if err != nil { // DB outage, not a bad token — carry the cause so the caller's log // distinguishes it from an ordinary invalid-token rejection. - return nil, "", 0, fmt.Errorf("auth: session lookup failed: %w", err) + return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err) } - return nil, "", 0, fmt.Errorf("auth: invalid session") + return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session") } if auth.IsSessionExpired(sess.ExpiresAt) { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("session expired")) - return nil, "", 0, fmt.Errorf("auth: session expired") + return nil, "", resumeHint{}, fmt.Errorf("auth: session expired") } user, err := database.GetUserByID(ctx, sess.UserID) if err != nil || user == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found")) if err != nil { - return nil, "", 0, fmt.Errorf("auth: user lookup failed: %w", err) + return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err) } - return nil, "", 0, fmt.Errorf("auth: user not found") + return nil, "", resumeHint{}, fmt.Errorf("auth: user not found") } if auth.IsEffectivelyBanned(user) { _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned")) - return nil, "", 0, fmt.Errorf("auth: banned user %d", user.ID) + return nil, "", resumeHint{}, fmt.Errorf("auth: banned user %d", user.ID) } - return user, hash, p.LastSeq, nil + return user, hash, resumeHint{LastSeq: p.LastSeq, ChannelID: p.ActiveChannelID}, nil } diff --git a/Server/ws/serve_failed_handshake_teardown_test.go b/Server/ws/serve_failed_handshake_teardown_test.go new file mode 100644 index 00000000..15bc7f22 --- /dev/null +++ b/Server/ws/serve_failed_handshake_teardown_test.go @@ -0,0 +1,157 @@ +package ws + +// serve_failed_handshake_teardown_test.go — regression tests for the two +// defects in unregisterFailedHandshake (serve.go). +// +// v039: the failure path mirrored only the presence half of readPump's defer, +// so a connection that inherited a transferred voice session left the +// voice_states row, the LiveKit participant and the E2EE key-holder entry +// standing until the next 60s sweep — and sweepStaleVoiceStates never +// re-elects the key holder, so rekey offers from the real lowest-uid +// participant were rejected with NOT_KEY_HOLDER in the meantime. +// +// v064: the offline presence broadcast shipped c.user.CustomStatus, a snapshot +// frozen at authentication (client.go assigns c.user exactly once), so a status +// the user changed or cleared mid-session was resurrected on every viewer. + +import ( + "bytes" + "context" + "testing" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// newTeardownTestDB opens an in-memory database with the full migration set. +func newTeardownTestDB(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 +} + +// TestFailedHandshake_TearsDownTransferredVoiceSession locks the voice half of +// the disconnect teardown on the handshake-failure path. +// +// Reachability, per the finding: on the replay-failure fallback the handshake +// deliberately keeps the voice_states row (handleFreshConnect) and registerNow +// transfers the live voice session onto the new client. If the auth_ok or ready +// write on that new socket then fails, no readPump ever starts, so +// unregisterFailedHandshake is the only teardown that can run. +func TestFailedHandshake_TearsDownTransferredVoiceSession(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "voice-handshake-fail", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + vcID, err := database.CreateChannel(ctx, "vc-teardown", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.JoinVoiceChannel(ctx, userID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + vs, err := database.GetVoiceState(ctx, userID) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState: %v", err) + } + + // Run is deliberately not started so h.broadcast can be inspected directly. + h := NewHub(database, auth.NewRateLimiter(), nil) + + // Old connection A holds the slot and the voice session; B replaces it on + // the replay-failure fallback (lastSeq > 0), inheriting the session. + oldClient := NewTestClient(h, userID, make(chan []byte, 64)) + oldClient.user = &db.User{ID: userID, Status: "online"} + oldClient.setVoiceState(vcID, vs.JoinedAt) + h.clients[userID] = oldClient + h.updateKeyHolder(vcID) + + newClient := NewTestClient(h, userID, make(chan []byte, 64)) + newClient.user = &db.User{ID: userID, Status: "online"} + newClient.lastSeq = 1 + h.registerNow(newClient, map[int64]bool{vcID: true}) + if replaced := h.unregisterNow(oldClient); !replaced { + t.Fatal("precondition: old client's defer must see itself replaced") + } + if got := newClient.getVoiceChID(); got != vcID { + t.Fatalf("precondition: registerNow must transfer the voice session, got voice_ch_id=%d", got) + } + if !h.IsVoiceKeyHolder(vcID, userID) { + t.Fatal("precondition: the transferred session must hold the E2EE key") + } + + // B's auth_ok/ready write fails; the handshake failure path runs. + h.unregisterFailedHandshake(ctx, newClient) + + if after, err := database.GetVoiceState(ctx, userID); err != nil { + t.Fatalf("GetVoiceState after teardown: %v", err) + } else if after != nil { + t.Errorf("voice_states row survived a failed handshake with no replacement connection: %+v", after) + } + if h.IsVoiceKeyHolder(vcID, userID) { + t.Error("voiceKeyHolders still names the departed connection — the real lowest-uid participant's rekey offers stay rejected") + } + + // The voice_leave broadcast must go out too, or every other client keeps + // rendering a tile for a participant that is gone. + sawVoiceLeave := false + for len(h.broadcast) > 0 { + bm := <-h.broadcast + if bytes.Contains(bm.msg, []byte(`"type":"`+MsgTypeVoiceLeaveBC+`"`)) { + sawVoiceLeave = true + } + } + if !sawVoiceLeave { + t.Error("no voice_leave broadcast queued after the failed handshake teardown") + } +} + +// TestFailedHandshake_OfflineBroadcastDropsStaleCustomStatus locks that the +// offline presence broadcast carries a null custom status rather than the +// auth-time snapshot on c.user, matching presentableMembers' rule that a member +// with no live connection shows no custom status. +func TestFailedHandshake_OfflineBroadcastDropsStaleCustomStatus(t *testing.T) { + database := newTeardownTestDB(t) + ctx := context.Background() + + userID, err := database.CreateUser(ctx, "stale-custom-status", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + h := NewHub(database, auth.NewRateLimiter(), nil) + + stale := "On vacation" + c := NewTestClient(h, userID, make(chan []byte, 8)) + c.user = &db.User{ID: userID, Status: "online", CustomStatus: &stale} + h.registerNow(c, nil) + + h.unregisterFailedHandshake(ctx, c) + + var presence []byte + for len(h.broadcast) > 0 { + bm := <-h.broadcast + if bytes.Contains(bm.msg, []byte(`"type":"`+MsgTypePresence+`"`)) { + presence = bm.msg + } + } + if presence == nil { + t.Fatal("no presence broadcast queued after the failed handshake teardown") + } + if bytes.Contains(presence, []byte(stale)) { + t.Errorf("offline broadcast resurrected the auth-time custom status: %s", presence) + } + if !bytes.Contains(presence, []byte(`"custom_status":null`)) { + t.Errorf("offline broadcast must clear custom_status explicitly (it is never omitempty): %s", presence) + } +} diff --git a/Server/ws/serve_pumps.go b/Server/ws/serve_pumps.go index c006f37f..f2f22db7 100644 --- a/Server/ws/serve_pumps.go +++ b/Server/ws/serve_pumps.go @@ -26,12 +26,42 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { return true } + // drainChannel writes every message still buffered on ch without blocking. + // Returns false only when a write failed; empty or closed is true. + drainChannel := func(ch chan []byte) bool { + for { + select { + case msg, ok := <-ch: + if !ok { + return true + } + if !writeMsg(msg) { + return false + } + default: + return true + } + } + } + + // drainAndClose flushes whatever the kick paths queued before closing the + // send channels (e.g. the BANNED error frame that makes the client clear + // its credentials) — serve.go and hub_broadcast.go both document that + // writePump drains remaining messages after closeSend. Returning on the + // first closed channel would drop those frames. + drainAndClose := func() { + if drainChannel(c.sendHigh) && drainChannel(c.send) { + drainChannel(c.sendLow) + } + _ = conn.Close(websocket.StatusNormalClosure, "") + } + for { // Priority 1: drain all pending high-priority messages first. select { case msg, ok := <-c.sendHigh: if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") + drainAndClose() return } if !writeMsg(msg) { @@ -41,13 +71,43 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { default: } - // Priority 2: try high or normal (high still gets priority via the - // first case in the select, but Go's select is random when both are - // ready — the outer drain-high loop above ensures high is truly first). + // Priority 2: try high or normal, non-blocking. Go's select among + // ready cases is uniformly random, so sendLow cannot be a peer here — + // a case that fires the moment any low-priority frame is queued would + // let it win the coin flip against a pending normal frame roughly + // half the time, contradicting "low-priority messages are only sent + // when no higher-priority work is pending". Only fall through to + // sendLow (via the blocking select below) once this default proves + // neither high nor normal has anything ready right now. select { case msg, ok := <-c.sendHigh: if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") + drainAndClose() + return + } + if !writeMsg(msg) { + return + } + continue + case msg, ok := <-c.send: + if !ok { + drainAndClose() + return + } + if !writeMsg(msg) { + return + } + continue + default: + } + + // Priority 3: nothing high or normal is ready — block on all three + // (plus shutdown) so an idle connection still gets its typing/presence + // frames instead of busy-looping. + select { + case msg, ok := <-c.sendHigh: + if !ok { + drainAndClose() return } if !writeMsg(msg) { @@ -55,7 +115,7 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { } case msg, ok := <-c.send: if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") + drainAndClose() return } if !writeMsg(msg) { @@ -63,7 +123,7 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { } case msg, ok := <-c.sendLow: if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") + drainAndClose() return } if !writeMsg(msg) { @@ -134,7 +194,13 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { // read time, where a member with no live connection renders // offline no matter what the column says. _ = hub.db.MarkUserDisconnected(cleanupCtx, c.userID) - hub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, c.user.CustomStatus)) + // custom_status is nil, not c.user.CustomStatus: that field is a + // snapshot taken once at auth (client.go) and never updated, so + // broadcasting it here would resurrect a status the user changed + // or cleared mid-session. presentableMembers applies the same + // rule for a fresh ready payload (serve_ready.go) — a member with + // no live connection shows no custom status. + hub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, nil)) } } }() diff --git a/Server/ws/serve_pumps_priority_test.go b/Server/ws/serve_pumps_priority_test.go new file mode 100644 index 00000000..61640a5a --- /dev/null +++ b/Server/ws/serve_pumps_priority_test.go @@ -0,0 +1,109 @@ +package ws + +// serve_pumps_priority_test.go — regression test for finding v089: writePump's +// blocking select used to list sendHigh, send, and sendLow as peer cases, so +// Go's uniformly-random selection among ready channels let a queued +// low-priority frame (typing/presence) win the race against a queued normal +// frame (chat/reactions) roughly half the time — contradicting the function's +// own documented "low only when no higher-priority work is pending" contract. + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" +) + +// TestWritePump_DrainsAllNormalBeforeAnyLow pre-queues N low-priority frames +// and N normal-priority frames on open (not yet closed) channels before +// writePump's loop can run at all, then reads all 2N frames back. If low ever +// won a coin flip against normal — the pre-fix behavior, since sendLow was a +// peer case in the very same blocking select as send — a typing frame would +// show up before some chat_message frame. +// +// The channels are deliberately left open (not closeSend'd) for the body of +// the test: a closed channel is always "select-ready", so closing before the +// pump starts would make its Priority-1 check immediately see sendHigh as +// ready-with-ok=false and short-circuit straight into drainAndClose, which +// drains each channel to completion in a fixed order — masking the exact +// per-message race this test exists to catch. The context is canceled only +// after every frame is read, so the pump exits cleanly via ctx.Done() +// instead of leaking (caught by TestMain's goleak check) or taking that +// shortcut. +func TestWritePump_DrainsAllNormalBeforeAnyLow(t *testing.T) { + const n = 200 + + c := &Client{ + userID: 1, + send: make(chan []byte, n), + sendHigh: make(chan []byte, n), + sendLow: make(chan []byte, n), + } + for i := range n { + c.sendLow <- []byte(`{"type":"typing","seq":` + strconv.Itoa(i) + `}`) + } + for i := range n { + c.send <- []byte(`{"type":"chat_message","seq":` + strconv.Itoa(i) + `}`) + } + + pumpCtx, pumpCancel := context.WithCancel(context.Background()) + defer pumpCancel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, acceptErr := websocket.Accept(w, r, nil) + if acceptErr != nil { + return + } + writePump(pumpCtx, conn, c) + // writePump only returns on ctx.Done() here (the test cancels pumpCtx + // once every frame is read) — nobody else closes the connection, so + // do it explicitly rather than leaving the client's own Close to wait + // out a full close-handshake timeout against a peer that already hung + // up its handler. + _ = conn.CloseNow() + })) + defer srv.Close() + + dialCtx, dialCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer dialCancel() + conn, resp, err := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.CloseNow() }() + + sawLow := false + for i := range 2 * n { + readCtx, readCancel := context.WithTimeout(context.Background(), 5*time.Second) + _, msg, readErr := conn.Read(readCtx) + readCancel() + if readErr != nil { + t.Fatalf("frame %d: read: %v", i, readErr) + } + switch { + case strings.Contains(string(msg), "typing"): + sawLow = true + case strings.Contains(string(msg), "chat_message"): + if sawLow { + t.Fatalf("frame %d: normal-priority frame arrived after a low-priority one: %s", i, msg) + } + default: + t.Fatalf("frame %d: unexpected payload: %s", i, msg) + } + } + if !sawLow { + t.Fatal("never observed a low-priority frame — test setup is broken") + } + + // Stop the pump cleanly via ctx.Done() now that every frame has been + // read, instead of relying on drainAndClose (closeSend) to end the loop. + pumpCancel() +} diff --git a/Server/ws/serve_ready.go b/Server/ws/serve_ready.go index a320dbf4..0f5fb1ea 100644 --- a/Server/ws/serve_ready.go +++ b/Server/ws/serve_ready.go @@ -137,8 +137,9 @@ func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { } // buildReady constructs the ready server→client message. -// Per PROTOCOL.md, channels include unread_count and last_message_id per user, -// and only protocol-specified fields (no slow_mode, archived, voice_* extras). +// Per docs/protocol.md, channels include unread_count and last_message_id per +// user, and only protocol-specified fields (no slow_mode, archived, voice_* +// extras). func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, role *db.Role) ([]byte, error) { channels, err := database.ListChannels(ctx) if err != nil { @@ -233,25 +234,11 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol channelPayloads = append(channelPayloads, entry) } - // Collect voice states, filtered to only visible channels (BUG-095). - allVoiceStates, err := collectAllVoiceStates(ctx, database, channels) - if err != nil { - // Non-fatal: send empty list rather than failing the whole ready payload. - slog.Warn("buildReady collectAllVoiceStates", "err", err) - allVoiceStates = []db.VoiceState{} - } - visibleSet := make(map[int64]struct{}, len(visibleChannels)) - for i := range visibleChannels { - visibleSet[visibleChannels[i].ID] = struct{}{} - } - voiceStates := make([]db.VoiceState, 0, len(allVoiceStates)) - for i := range allVoiceStates { - if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok { - voiceStates = append(voiceStates, allVoiceStates[i]) - } - } - - // Load open DM channels for this user. + // Load open DM channels for this user. Hoisted above the voice-state + // filter below so DM channel IDs can seed visibleSet — permissions.Checker + // (and therefore visibleChannels) deliberately skips DM channels, since + // their visibility is membership-based rather than role-based, so without + // this a DM voice call's voice_state rows would never make it into ready. dmChannels, err := database.GetUserDMChannels(ctx, userID) if err != nil { slog.Warn("buildReady GetUserDMChannels", "err", err) @@ -266,6 +253,32 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol } } + // Collect voice states, filtered to visible channels (BUG-095) plus the + // user's own open DM channels — mirroring computeAllowedChannels, which + // layers DM IDs onto the same checker result for reconnect replay + // filtering. Without this, a DM voice call's voice_state rows are + // structurally unreachable: VisibleChannelIDs skips ch.Type == "dm", and + // nothing else re-adds them for this filter. + allVoiceStates, err := collectAllVoiceStates(ctx, database, channels) + if err != nil { + // Non-fatal: send empty list rather than failing the whole ready payload. + slog.Warn("buildReady collectAllVoiceStates", "err", err) + allVoiceStates = []db.VoiceState{} + } + visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels)) + for i := range visibleChannels { + visibleSet[visibleChannels[i].ID] = struct{}{} + } + for i := range dmChannels { + visibleSet[dmChannels[i].ChannelID] = struct{}{} + } + voiceStates := make([]db.VoiceState, 0, len(allVoiceStates)) + for i := range allVoiceStates { + if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok { + voiceStates = append(voiceStates, allVoiceStates[i]) + } + } + serverName, motd := h.getCachedSettings(ctx) return buildJSON(map[string]any{ diff --git a/Server/ws/serve_ready_dm_voice_test.go b/Server/ws/serve_ready_dm_voice_test.go new file mode 100644 index 00000000..da719c5e --- /dev/null +++ b/Server/ws/serve_ready_dm_voice_test.go @@ -0,0 +1,65 @@ +package ws_test + +// serve_ready_dm_voice_test.go — regression test for finding v016: buildReady +// filtered voice_states through visibleSet, which is built only from +// permissions.Checker.VisibleChannelIDs — a predicate that deliberately skips +// every DM channel (DM visibility is membership-based, not role-based). That +// meant a DM voice call could never appear in a full ready payload, so a +// mid-call full re-sync (e.g. after a brief WS drop that fails replay) wiped +// the client's DM voice roster even though the call was still live. + +import ( + "context" + "encoding/json" + "testing" +) + +// TestBuildReady_IncludesDMVoiceStates locks that a voice_state for an open DM +// channel survives buildReady's visibility filter. Before the fix, visibleSet +// was seeded only from visibleChannels (server channels gated on +// READ_MESSAGES), so this DM's voice state was silently dropped. +func TestBuildReady_IncludesDMVoiceStates(t *testing.T) { + hub, database := newServeHub(t) + + viewer := seedServeUser(t, database, "dm-voice-viewer") + other := seedServeUser(t, database, "dm-voice-other") + viewerRole, err := database.GetRoleByID(context.Background(), viewer.RoleID) + if err != nil || viewerRole == nil { + t.Fatalf("GetRoleByID: %v", err) + } + + dmChannelID := seedDMChannel(t, database, viewer.ID, other.ID) + + // The other participant joins the DM's voice channel. + if err := database.JoinVoiceChannel(context.Background(), other.ID, dmChannelID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, viewer.ID, viewerRole) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + + var env struct { + Payload struct { + VoiceStates []struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + } `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + found := false + for _, vs := range env.Payload.VoiceStates { + if vs.ChannelID == dmChannelID && vs.UserID == other.ID { + found = true + } + } + if !found { + t.Fatalf("ready payload's voice_states is missing the DM call (channel_id=%d, user_id=%d); got %+v", + dmChannelID, other.ID, env.Payload.VoiceStates) + } +} diff --git a/Server/ws/voice_audience_test.go b/Server/ws/voice_audience_test.go new file mode 100644 index 00000000..d1a66e46 --- /dev/null +++ b/Server/ws/voice_audience_test.go @@ -0,0 +1,110 @@ +package ws + +import ( + "context" + "slices" + "testing" +) + +// Voice membership is gated on CONNECT_VOICE alone (voice_join), but the +// voice_state / voice_leave fan-out filters its audience on READ_MESSAGES. A +// participant in that gap misses the room's own membership events — and the +// client's E2EE key-holder election and forward-secrecy rotation run only off +// the voice_leave WS event, so a departing key holder is never replaced and +// new joiners hang until the e2ee_timeout eject. A room's current +// participants must always be in its voice-event audience; the READ filter +// only decides what outsiders may observe. +// +// The bare test hub resolves an empty (fail-closed) READ audience, which is +// exactly the participant-excluded state the union must repair. +func TestBroadcastVoiceEvent_VoiceParticipantsAlwaysInAudience(t *testing.T) { + h := newEmitTestHub() + + participant := NewTestClient(h, 1, make(chan []byte, 8)) + h.clients[1] = participant + participant.setVoiceState(5, "join-token-1") + + otherRoom := NewTestClient(h, 2, make(chan []byte, 8)) + h.clients[2] = otherRoom + otherRoom.setVoiceState(6, "join-token-2") + + outsider := NewTestClient(h, 3, make(chan []byte, 8)) + h.clients[3] = outsider + + h.broadcastVoiceEvent(context.Background(), 5, buildVoiceLeave(5, 1)) + + select { + case bm := <-h.broadcast: + if !slices.Contains(bm.recipients, int64(1)) { + t.Error("voice participant missing from its own room's voice-event audience") + } + if slices.Contains(bm.recipients, int64(2)) { + t.Error("participant of a different room leaked into the audience") + } + if slices.Contains(bm.recipients, int64(3)) { + t.Error("non-participant without READ leaked into the audience") + } + default: + t.Fatal("broadcastVoiceEvent enqueued nothing") + } +} + +// TestFinishVoiceLeave_EvictedUserAlwaysInAudience locks the fix for v021: +// by the time finishVoiceLeave runs, the caller has already cleared the +// evicted client's own voice state, so broadcastVoiceEvent's participant +// union (which checks c.getVoiceChID() == channelID) can no longer find +// them — and voice membership is gated on CONNECT_VOICE alone, so a +// participant without READ_MESSAGES is a supported state that would +// otherwise never receive its own voice_leave teardown signal (the client's +// only trigger for E2EE/LiveKit cleanup on a server-initiated eviction). +// finishVoiceLeave must resolve the audience itself and always include the +// evicted user — WITHOUT losing broadcastVoiceEvent's union of the room's +// remaining participants, who are in the same CONNECT_VOICE-without-READ gap +// the sibling test above covers. +func TestFinishVoiceLeave_EvictedUserAlwaysInAudience(t *testing.T) { + h := newEmitTestHub() + + evicted := NewTestClient(h, 1, make(chan []byte, 8)) + h.clients[1] = evicted + // The real callers (handleVoiceLeave, handleVoiceLeaveIfStillIn) clear the + // client's voice state before calling finishVoiceLeave; evicted's + // getVoiceChID() is already 0 here, exactly like at the real call site. + + outsider := NewTestClient(h, 2, make(chan []byte, 8)) + h.clients[2] = outsider + + // Still in the room the leaver is being removed from, and (bare hub) + // without READ: they must still be told, or their client keeps a stale + // E2EE key holder for the channel. + staying := NewTestClient(h, 3, make(chan []byte, 8)) + h.clients[3] = staying + staying.setVoiceState(5, "join-token-3") + + otherRoom := NewTestClient(h, 4, make(chan []byte, 8)) + h.clients[4] = otherRoom + otherRoom.setVoiceState(6, "join-token-4") + + // Empty join token skips the DB delete (leaveVoiceChannelWithRetry treats + // "" as "nothing to remove") — this bare hub has no DB, matching the + // bare-hub fail-closed (empty) READ audience the sibling test above + // exercises for broadcastVoiceEvent. + h.finishVoiceLeave(context.Background(), evicted, 5, "") + + select { + case bm := <-h.broadcast: + if !slices.Contains(bm.recipients, int64(1)) { + t.Error("evicted user missing from its own voice_leave audience") + } + if slices.Contains(bm.recipients, int64(2)) { + t.Error("non-participant without READ leaked into the audience") + } + if !slices.Contains(bm.recipients, int64(3)) { + t.Error("remaining participant of the leaver's room missing from the audience") + } + if slices.Contains(bm.recipients, int64(4)) { + t.Error("participant of a different room leaked into the audience") + } + default: + t.Fatal("finishVoiceLeave enqueued nothing") + } +} diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index c83ac278..29be78fb 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -88,17 +88,31 @@ func handleVoiceCameraV2(ctx context.Context, cmd Command, info ClientInfo, deps return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - // Permission check. - if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { - return *r - } - enabled := cameraCmd.Enabled() + // Only the enable direction is gated on USE_VIDEO — mirrors + // handleVoiceMuteV2/handleVoiceDeafenV2's asymmetric gate: once a + // moderator revokes the permission mid-call, the user must still be able + // to turn their camera off, or voice_states.camera stays stuck at 1 — + // permanently burning a voice_max_video slot — until they leave voice, + // since nothing else ever clears it. + if enabled { + if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { + return *r + } + } + // Enforce MaxVideo limit when enabling camera using an atomic check-and-update. if enabled { ch, chErr := d.DB.GetChannel(ctx, voiceChID) - if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 { + if chErr != nil { + // Fail closed: an unreadable channel row is not "no cap + // configured" — falling through to the unconditional enable + // bypasses the per-channel video limit. + slog.Error("handleVoiceCameraV2 GetChannel", "err", chErr, "channel_id", voiceChID) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check video limit"}} + } + if ch != nil && ch.VoiceMaxVideo > 0 { ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo) if limitErr != nil { slog.Error("handleVoiceCameraV2 EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID) @@ -143,16 +157,25 @@ func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - // Permission check. - if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { - return *r + enabled := ssCmd.Enabled() + + // Only the enable direction is gated on SHARE_SCREEN — mirrors + // handleVoiceMuteV2/handleVoiceDeafenV2's asymmetric gate: once a + // moderator revokes the permission mid-share, the user must still be able + // to stop sharing, or voice_states.screenshare stays stuck at 1 — every + // subsequent voice_state keeps advertising a stream nobody can watch — + // until they leave voice. + if enabled { + if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { + return *r + } } - if err := d.DB.UpdateVoiceScreenshare(ctx, userID, ssCmd.Enabled()); err != nil { + if err := d.DB.UpdateVoiceScreenshare(ctx, userID, enabled); err != nil { slog.Error("ws handleVoiceScreenshareV2 UpdateVoiceScreenshare", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update screenshare state"}} } - slog.Debug("voice screenshare changed", "user_id", userID, "enabled", ssCmd.Enabled(), "channel_id", voiceChID) + slog.Debug("voice screenshare changed", "user_id", userID, "enabled", enabled, "channel_id", voiceChID) return voiceStateBroadcast(ctx, d, userID) } diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index 4942ef8d..365dc604 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -10,6 +10,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/ws" ) @@ -287,6 +288,39 @@ func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) { } } +// TestVoice_Join_RejectsNonVoiceChannel locks the fix for v030: a voice_join +// targeting a text (or other non-voice, non-dm) channel must be refused +// before any state is persisted — hasChannelAccess only knows CONNECT_VOICE, +// not channel type, so without an explicit type check a crafted voice_join +// for a text channel would otherwise mint a LiveKit room and voice_state +// broadcast for a channel the UI can never render. +func TestVoice_Join_RejectsNonVoiceChannel(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "text-join") + chanID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + + if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" { + t.Fatalf("error code = %q, want BAD_REQUEST", code) + } + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state != nil { + t.Error("voice_states row persisted for a voice_join against a text channel") + } +} + func TestVoice_Join_NoPermission_SendsError(t *testing.T) { hub, database := newVoiceHub(t) chanID := seedVoiceChan(t, database, "vc-noperm") @@ -542,6 +576,77 @@ func TestVoice_Camera_UpdatesState(t *testing.T) { } } +// TestVoice_Camera_DisableAllowedAfterPermissionRevoked locks the fix for +// v033: only the enable direction is gated on USE_VIDEO, mirroring +// handleVoiceMuteV2/handleVoiceDeafenV2's asymmetric gate. If disable were +// still gated too, a moderator revoking USE_VIDEO mid-call would leave +// voice_states.camera stuck at 1 — permanently burning a voice_max_video slot +// — because the user could never send a voice_camera{enabled:false} that +// passes the permission check. +func TestVoice_Camera_DisableAllowedAfterPermissionRevoked(t *testing.T) { + hub, database := newVoiceHub(t) + + // A custom role carrying USE_VIDEO (the test schema's default Member role + // does not, unlike production's migration 007). + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (100, 'hasvideo', NULL, ?, 5, 0)`, + permissions.ReadMessages|permissions.ConnectVoice|permissions.UseVideo, + ); err != nil { + t.Fatalf("seed hasvideo role: %v", err) + } + user := seedVoiceUserWithRole(t, database, "cam-revoked", 100) + chanID := seedVoiceChan(t, database, "vc-cam-revoked") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + drainChanTimeout(send, 30*time.Millisecond) + + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + drainChanTimeout(send, 30*time.Millisecond) + + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Camera { + t.Fatal("camera should be enabled before the permission is revoked") + } + + // Revoke USE_VIDEO mid-call by reassigning to a role that lacks it. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (101, 'novideo', NULL, ?, 5, 0)`, + permissions.ReadMessages|permissions.ConnectVoice, + ); err != nil { + t.Fatalf("seed novideo role: %v", err) + } + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET role_id = 101 WHERE id = ?`, user.ID); err != nil { + t.Fatalf("reassign user role: %v", err) + } + + hub.HandleMessageForTest(c, voiceCameraMsg(false)) + + msgs := drainChanTimeout(send, 30*time.Millisecond) + for _, m := range msgs { + if extractType(t, m) == "error" { + t.Fatalf("unexpected error disabling camera after permission revocation: %s", extractCode(t, m)) + } + } + state, err = database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState after disable: %v", err) + } + if state == nil || state.Camera { + t.Error("camera still enabled after voice_camera(false), permission revocation must not block disabling") + } +} + // TestVoice_Camera_NoPermission: Member without USE_VIDEO gets FORBIDDEN. func TestVoice_Camera_NoPermission(t *testing.T) { hub, _ := newVoiceHub(t) @@ -659,6 +764,76 @@ func TestVoice_Screenshare_UpdatesState(t *testing.T) { } } +// TestVoice_Screenshare_DisableAllowedAfterPermissionRevoked locks the fix +// for v082: only the enable direction is gated on SHARE_SCREEN, mirroring +// handleVoiceMuteV2/handleVoiceDeafenV2's asymmetric gate. If disable were +// still gated too, a moderator revoking SHARE_SCREEN mid-share would leave +// voice_states.screenshare stuck at 1, so every subsequent voice_state kept +// advertising a stream nobody can watch. +func TestVoice_Screenshare_DisableAllowedAfterPermissionRevoked(t *testing.T) { + hub, database := newVoiceHub(t) + + // A custom role carrying SHARE_SCREEN (the test schema's default Member + // role does not, unlike production's migration 007). + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (200, 'hasscreenshare', NULL, ?, 5, 0)`, + permissions.ReadMessages|permissions.ConnectVoice|permissions.ShareScreen, + ); err != nil { + t.Fatalf("seed hasscreenshare role: %v", err) + } + user := seedVoiceUserWithRole(t, database, "ss-revoked", 200) + chanID := seedVoiceChan(t, database, "vc-ss-revoked") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + drainChanTimeout(send, 30*time.Millisecond) + + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + drainChanTimeout(send, 30*time.Millisecond) + + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Screenshare { + t.Fatal("screenshare should be enabled before the permission is revoked") + } + + // Revoke SHARE_SCREEN mid-share by reassigning to a role that lacks it. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (102, 'noscreenshare', NULL, ?, 5, 0)`, + permissions.ReadMessages|permissions.ConnectVoice, + ); err != nil { + t.Fatalf("seed noscreenshare role: %v", err) + } + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET role_id = 102 WHERE id = ?`, user.ID); err != nil { + t.Fatalf("reassign user role: %v", err) + } + + hub.HandleMessageForTest(c, voiceScreenshareMsg(false)) + + msgs := drainChanTimeout(send, 30*time.Millisecond) + for _, m := range msgs { + if extractType(t, m) == "error" { + t.Fatalf("unexpected error disabling screenshare after permission revocation: %s", extractCode(t, m)) + } + } + state, err = database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState after disable: %v", err) + } + if state == nil || state.Screenshare { + t.Error("screenshare still enabled after voice_screenshare(false), permission revocation must not block disabling") + } +} + // TestVoice_Screenshare_NoPermission: client without SHARE_SCREEN gets FORBIDDEN. func TestVoice_Screenshare_NoPermission(t *testing.T) { hub, _ := newVoiceHub(t) @@ -943,6 +1118,64 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { } } +// TestVoice_Join_SwitchChannel_PreservesServerMute locks the fix for v029: a +// moderator-imposed server mute/deafen must survive a channel switch. The +// switch path deletes and re-inserts the voice_states row (via +// handleVoiceLeave then JoinVoiceChannel), which would otherwise never reach +// the ON CONFLICT branch voice.sql relies on to keep server_muted sticky — +// silently lifting the mute the moment the user changes channels. +func TestVoice_Join_SwitchChannel_PreservesServerMute(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "switch-muted") + chanA := seedVoiceChan(t, database, "vc-switch-muted-a") + chanB := seedVoiceChan(t, database, "vc-switch-muted-b") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanA, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanA)) + drainChanTimeout(send, 30*time.Millisecond) + + if err := database.SetVoiceServerMute(context.Background(), user.ID, true); err != nil { + t.Fatalf("SetVoiceServerMute: %v", err) + } + if err := database.SetVoiceServerDeafen(context.Background(), user.ID, true); err != nil { + t.Fatalf("SetVoiceServerDeafen: %v", err) + } + + // Switch to channel B. + hub.HandleMessageForTest(c, voiceJoinMsg(chanB)) + + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState after switch: %v", err) + } + if state == nil || state.ChannelID != chanB { + t.Fatalf("user should be in channel B after switching, got %+v", state) + } + if !state.ServerMuted { + t.Error("ServerMuted was cleared by the channel switch, want it to survive") + } + if !state.ServerDeafened { + t.Error("ServerDeafened was cleared by the channel switch, want it to survive") + } +} + +// NOTE on v088 (BUG-088, the join/sweep ghost race): handleVoiceJoin now +// calls c.setVoiceState immediately after the DB row commits instead of after +// the permission checks and LiveKit token generation that used to follow it, +// and re-checks (channel, join token) before subscribing and broadcasting so +// an eviction that lands during the token round trip is not undone. Neither +// half is tested here: HandleMessageForTest runs the handler synchronously to +// completion on the calling goroutine, so any assertion made after it returns +// reflects only the final state and cannot distinguish the old ordering from +// the new one — a genuine repro needs a second goroutine to land inside a +// live handler call. What remains after this change is the window before the +// row commits, which only a grace-period guard in sweepStaleVoiceStates +// (hub_sweep.go, outside this batch) can close — see cross_batch. + // TestVoice_Join_SameChannel_IsIdempotent verifies that joining the same channel // twice returns ALREADY_JOINED. func TestVoice_Join_SameChannel_IsIdempotent(t *testing.T) { diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index 88dd80c1..57b3c529 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -73,6 +73,27 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe return } + // channel_id is attacker-controlled and requireChannelAccess above only + // gates CONNECT_VOICE, which says nothing about channel type — a text or + // announcement channel would otherwise accept a join, persist a + // voice_states row, mint a LiveKit room and broadcast voice_state for a + // channel the UI can never render or moderate. 'dm' stays allowed: DM and + // group voice calls join through this same handler. + if ch.Type != "voice" && ch.Type != "dm" { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "not a voice channel")) + return + } + + // Archived channels are hidden from every client and their voice states are + // dropped from `ready`, but `archived` was consulted only by the visibility + // predicate — so a caller still holding the id could join the room of a + // channel nobody can see or moderate. Refuse the join outright; the sibling + // archive transition also evicts whoever is already inside. + if ch.Archived { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel is archived")) + return + } + // Ensure authenticated user is present before any state changes. // This guard covers all downstream paths (LiveKit configured or not) // that dereference c.user (e.g. c.user.Username in the success log). @@ -105,6 +126,26 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe return } + // A moderator-imposed mute/deafen must survive a channel switch. + // voice.sql's ON CONFLICT branch preserves server_muted/server_deafened + // across a plain re-join, but the switch below deletes the row via + // handleVoiceLeave and lets JoinVoiceChannel(IfCapacity) re-insert it, so + // that branch is never reached: the flags are snapshotted here and + // reapplied once the new row exists. + // + // This covers the self-switch only. voice_mod_move deletes the row on the + // moderator's goroutine (DisconnectFromVoice) before the target's client + // re-joins, so by the time this handler runs there is nothing left to read + // and currentChID is already 0 — preserving the flags across a move needs + // state that outlives the row (see the cross-batch note on v029). + var wasServerMuted, wasServerDeafened bool + if currentChID > 0 { + if prevState, prevErr := h.db.GetVoiceState(ctx, c.userID); prevErr == nil && prevState != nil { + wasServerMuted = prevState.ServerMuted + wasServerDeafened = prevState.ServerDeafened + } + } + // If user is already in a different voice channel, leave it first. if currentChID > 0 { h.handleVoiceLeave(ctx, c) @@ -124,8 +165,15 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe if vs != nil { slog.Warn("handleVoiceJoin: stale voice state persists after leave, aborting switch", "user_id", c.userID, "stale_channel", vs.ChannelID, "target_channel", channelID) - // Restore client voice state so the user knows they're still in the old channel. + // Restore client voice state so the user knows they're still in the + // old channel. The failed leave already dropped the voice-topic + // subscription and key-holder entry, and voice state and topic + // subscription must move as a pair (see clearVoiceAndUnsubscribe) + // — without them the restored session silently misses every + // voice_e2ee relay for its channel. c.setVoiceState(vs.ChannelID, vs.JoinedAt) + h.pubsub.Subscribe(c, VoiceTopic(vs.ChannelID)) + h.updateKeyHolder(vs.ChannelID) c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice channel switch failed — please try again")) return } @@ -162,11 +210,55 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe return } + // BUG-088: set the client's voice channel as soon as the DB row is + // confirmed committed, before the permission checks and LiveKit token + // generation below (which can take several round trips). Leaving this + // until after those steps left a window where the concurrent stale-voice + // sweep sees c.getVoiceChID() still 0 while the row already exists, + // misclassifies the in-flight join as a ghost and deletes it — leaving + // the joiner live on the hub and in the SFU with no DB row. A failure + // further down still unwinds this via rollbackVoiceJoin's + // c.clearVoiceChID(), same as before. + c.setVoiceState(channelID, state.JoinedAt) + + // Restore a moderator-imposed mute/deafen that predates this switch (see + // the snapshot above). Best-effort: a failure here is logged but does not + // fail the join, matching every other SetVoiceServerMute/Deafen call site. + if wasServerMuted || wasServerDeafened { + if wasServerMuted { + if err := h.db.SetVoiceServerMute(ctx, c.userID, true); err != nil { + slog.Error("ws handleVoiceJoin SetVoiceServerMute (restore)", "err", err, "user_id", c.userID) + } + } + if wasServerDeafened { + if err := h.db.SetVoiceServerDeafen(ctx, c.userID, true); err != nil { + slog.Error("ws handleVoiceJoin SetVoiceServerDeafen (restore)", "err", err, "user_id", c.userID) + } + } + // Re-read so the voice_state broadcast below carries the restored + // flags rather than the plain-insert defaults — that broadcast is what + // makes the mute effective on the target's own client and visible to + // everyone else. + // + // No SFU mute is applied here: MuteParticipantAudio resolves the + // participant in the destination room first, and this join has not even + // minted its token yet, so the call could only fail (after a LiveKit + // round trip on the read pump). As everywhere else in the voice + // moderation path, the persisted server_muted is the authority — it + // blocks the target's own unmute and is re-applied at the SFU whenever + // the moderator next acts. + if refreshed, refErr := h.db.GetVoiceState(ctx, c.userID); refErr == nil && refreshed != nil { + state = refreshed + } + } + // Generate LiveKit token if LiveKit client is available. // Token generation failure is fatal — without a token the client cannot // connect to the SFU, so we must roll back the DB join. - // NOTE: setVoiceState is deferred until after token send succeeds, so - // rollback does not broadcast a spurious voice_leave for an unannounced join. + // NOTE: the joiner's own state was already set above (BUG-088), but + // nobody else has been told about the join yet — rollbackVoiceJoin below + // is still called with broadcast=false, so a failure here does not + // broadcast a spurious voice_leave for a join no other client ever saw. if h.livekit != nil { // Derive publish permissions from role — prevents SFU-level bypass // when client connects directly via direct_url (BUG-128). With a @@ -220,8 +312,20 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL(), isKeyHolder)) } - // Set voice channel on the client AFTER token is sent successfully. - c.setVoiceState(channelID, state.JoinedAt) + // Voice channel state itself was already set above (BUG-088), immediately + // after the DB row committed — which also means a concurrent eviction (the + // revocation sweep, a participant_left webhook, a moderator kick/move) can + // now land on THIS join instance while the token round trip above is in + // flight. Those all clear the client's voice state and delete the row after + // deciding against it, so completing the join here would resurrect a + // membership that was deliberately torn down: subscribed to the voice + // topic and broadcast as present, with no row behind it. Their decision + // wins; a same-instance state is the only thing this join may finish. + if curChID, curToken := c.getVoiceState(); curChID != channelID || curToken != state.JoinedAt { + slog.Info("ws handleVoiceJoin: join superseded before completion", + "user_id", c.userID, "channel_id", channelID, "current_channel_id", curChID) + return + } // Subscribe to voice topic for voice-scoped events. h.pubsub.Subscribe(c, VoiceTopic(channelID)) @@ -359,6 +463,13 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo // voice_leave so other clients don't see a ghost participant. func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, broadcast bool) { c.clearVoiceChID() + // The client's voice state is now set before token generation (BUG-088), + // so a concurrent join/leave in the same channel can have elected this + // half-joined client key holder. Re-run the election after taking it back + // out, or the map keeps naming a user who never reached the SFU and the + // real lowest-uid participant's rekey offers are rejected with + // NOT_KEY_HOLDER until the next join or leave. + h.updateKeyHolder(channelID) // The compensating delete must run even when the join failed BECAUSE the // connection died — that cancellation is the most common rollback trigger. if err := h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID); err != nil { diff --git a/Server/ws/voice_keyholder_test.go b/Server/ws/voice_keyholder_test.go new file mode 100644 index 00000000..2de2a362 --- /dev/null +++ b/Server/ws/voice_keyholder_test.go @@ -0,0 +1,148 @@ +package ws + +import "testing" + +// voiceKeyHolders names the participant whose voice_e2ee_offers the server will +// accept. Any path that removes a participant from voice must re-elect, or the +// map keeps naming someone who is gone: the real lowest-uid participant's rekey +// offers are then rejected with NOT_KEY_HOLDER (which the client does not +// handle) after it has already applied its rotated key locally, splitting keys +// across the room. + +// setupVoiceRoom puts users 1 and 2 in voice channel 5 and elects user 1. +func setupVoiceRoom(t *testing.T) (*Hub, *Client, *Client) { + t.Helper() + h := newEmitTestHub() + + c1 := NewTestClient(h, 1, make(chan []byte, 8)) + c2 := NewTestClient(h, 2, make(chan []byte, 8)) + h.clients[1] = c1 + h.clients[2] = c2 + c1.setVoiceState(5, "join-token-1") + c2.setVoiceState(5, "join-token-2") + + h.updateKeyHolder(5) + if !h.IsVoiceKeyHolder(5, 1) { + t.Fatal("pre-condition: user 1 (lowest uid) should be the elected key holder") + } + return h, c1, c2 +} + +// The LiveKit participant_left webhook is the media-only-loss path: the WS stays +// up, so nothing else will clean this participant out of voice. +func TestWebhookParticipantLeft_ReelectsKeyHolder(t *testing.T) { + h, _, _ := setupVoiceRoom(t) + + h.HandleWebhookParticipantLeftForTest(1, 5, "join-token-1") + + if h.IsVoiceKeyHolder(5, 1) { + t.Error("departed participant is still the key holder") + } + if !h.IsVoiceKeyHolder(5, 2) { + t.Error("key holder was not re-elected to the lowest remaining participant") + } +} + +// A fresh reconnect (lastSeq == 0, e.g. F5) replaces the old connection and +// drops its voice state without transferring it, so the room loses that +// participant — but registerNow never re-elected. +func TestRegisterNow_ReelectsKeyHolderWhenReplacedClientLeavesVoice(t *testing.T) { + h, _, _ := setupVoiceRoom(t) + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + h.registerNow(replacement, map[int64]bool{5: true}) + + if voiceChID := replacement.getVoiceChID(); voiceChID != 0 { + t.Fatalf("pre-condition: fresh connect should not inherit voice state, got channel %d", voiceChID) + } + if h.IsVoiceKeyHolder(5, 1) { + t.Error("user 1 is still the key holder after its connection left voice") + } + if !h.IsVoiceKeyHolder(5, 2) { + t.Error("key holder was not re-elected to the lowest remaining participant") + } +} + +// The re-election must not fire when the reconnect transfers voice state: +// user 1 is still in the room, so it stays the key holder. +func TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers(t *testing.T) { + h, _, _ := setupVoiceRoom(t) + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 // network reconnect — voice state is preserved + h.registerNow(replacement, map[int64]bool{5: true}) + + if voiceChID := replacement.getVoiceChID(); voiceChID != 5 { + t.Fatalf("pre-condition: network reconnect should inherit voice state, got channel %d", voiceChID) + } + if !h.IsVoiceKeyHolder(5, 1) { + t.Error("key holder was re-elected away from user 1, which is still in voice") + } +} + +// The webhook path takes a participant out of voice while the WS stays up, so +// it must also drop the voice-topic subscription — otherwise the client keeps +// receiving that room's voice_e2ee_announce relays (which carry no channel_id +// the client could filter on) for the socket's lifetime, and a later join to a +// different channel TOFU-pins stale cross-room keys. +func TestWebhookParticipantLeft_UnsubscribesVoiceTopic(t *testing.T) { + h, c1, _ := setupVoiceRoom(t) + h.pubsub.Subscribe(c1, VoiceTopic(5)) // as the real voice_join flow does + + h.HandleWebhookParticipantLeftForTest(1, 5, "join-token-1") + + h.pubsub.mu.RLock() + sub := h.pubsub.topics[VoiceTopic(5)][1] + h.pubsub.mu.RUnlock() + if sub != nil { + t.Error("client is still subscribed to the voice topic after webhook participant_left") + } +} + +// A network reconnect (lastSeq > 0) keeps the user in voice, so the resumed +// connection must stay subscribed to the voice topic — the only transport for +// voice_e2ee_announce relays — and must retain the announced E2EE key that +// voice_join replays to future joiners. voice_join cannot repair either after +// the fact: a same-channel rejoin is rejected with ALREADY_JOINED. +func TestRegisterNow_ResumeRestoresVoiceTopicAndE2EEKey(t *testing.T) { + h, c1, _ := setupVoiceRoom(t) + c1.setE2EEPubKey("ecdh-pub-1", "identity-sig-1") + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 // network reconnect — voice state is preserved + h.registerNow(replacement, map[int64]bool{5: true}) + + h.pubsub.mu.RLock() + sub := h.pubsub.topics[VoiceTopic(5)][1] + h.pubsub.mu.RUnlock() + if sub != replacement { + t.Error("resumed connection is not subscribed to its voice channel's topic") + } + key, sig := replacement.getE2EEPubKey() + if key != "ecdh-pub-1" || sig != "identity-sig-1" { + t.Errorf("announced E2EE key was not transferred on resume: got key=%q sig=%q", key, sig) + } +} + +// The voice-topic subscription must not depend on READ_MESSAGES: it carries +// only E2EE frames for a channel the user already joined through the +// CONNECT_VOICE-gated voice_join. Only the message-stream ChannelTopic is +// READ-gated. +func TestRegisterNow_ResumeVoiceTopicIgnoresReadGate(t *testing.T) { + h, _, _ := setupVoiceRoom(t) + + replacement := NewTestClient(h, 1, make(chan []byte, 8)) + replacement.lastSeq = 1 + h.registerNow(replacement, nil) // no READ_MESSAGES anywhere + + h.pubsub.mu.RLock() + voiceSub := h.pubsub.topics[VoiceTopic(5)][1] + chanSub := h.pubsub.topics[ChannelTopic(5)][1] + h.pubsub.mu.RUnlock() + if voiceSub != replacement { + t.Error("voice topic subscription must not be gated on READ_MESSAGES") + } + if chanSub != nil { + t.Error("channel topic subscription must stay READ-gated") + } +} diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index 901cfc68..9e9507ba 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -6,17 +6,54 @@ import ( "time" ) +// clearVoiceAndUnsubscribe clears c's voice state and drops its voice-topic +// subscription, returning the cleared channel ID and join token. Every path +// that takes a client out of voice while its WS stays up must use this pair: +// clearing state without unsubscribing leaves the socket receiving that room's +// voice_e2ee_announce relays (which carry no channel_id to filter on) for the +// connection's lifetime, polluting a later session's peer-key store. +func (h *Hub) clearVoiceAndUnsubscribe(c *Client) (int64, string) { + oldChID, oldJoinToken := c.clearVoiceState() + if oldChID != 0 { + h.pubsub.Unsubscribe(c, VoiceTopic(oldChID)) + } + return oldChID, oldJoinToken +} + // handleVoiceLeave processes an explicit voice_leave message or a disconnect. -// 1. Gets old voiceChID from clearVoiceChID(). +// 1. Gets old voiceChID from clearVoiceAndUnsubscribe. // 2. If was in voice: remove from DB (with retry), broadcast voice_leave. // 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone). func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { - oldChID, oldJoinToken := c.clearVoiceState() + oldChID, oldJoinToken := h.clearVoiceAndUnsubscribe(c) if oldChID == 0 { slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID) return } + h.finishVoiceLeave(ctx, c, oldChID, oldJoinToken) +} +// handleVoiceLeaveIfStillIn is handleVoiceLeave conditioned on the channel: it +// evicts only if chID is still the client's current voice channel, reporting +// whether it did. An eviction decided against a snapshotted channel (the +// revocation sweep's DB-backed permission check) must not clear a newer +// membership committed while the decision was in flight — the same rule +// LeaveVoiceChannelIfMatch applies to the DB row. +func (h *Hub) handleVoiceLeaveIfStillIn(ctx context.Context, c *Client, chID int64) bool { + oldJoinToken, ok := c.clearVoiceStateIfMatch(chID) + if !ok { + return false + } + h.pubsub.Unsubscribe(c, VoiceTopic(chID)) + h.finishVoiceLeave(ctx, c, chID, oldJoinToken) + return true +} + +// finishVoiceLeave is the shared tail of the leave paths, run after the +// client's voice state and topic subscription are cleared: DB row removal +// (with retry), voice_leave broadcast, key-holder re-election and LiveKit +// participant removal. +func (h *Hub) finishVoiceLeave(ctx context.Context, c *Client, oldChID int64, oldJoinToken string) { username := "" if c.user != nil { username = c.user.Username @@ -28,14 +65,38 @@ func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { "remote", c.remoteAddr, ) - // Unsubscribe from voice topic. - h.pubsub.Unsubscribe(c, VoiceTopic(oldChID)) - if err := leaveVoiceChannelWithRetry(ctx, h, c.userID, oldChID, oldJoinToken); err != nil { c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice leave failed — please rejoin if issues persist")) } - h.broadcastVoiceEvent(ctx, oldChID, buildVoiceLeave(oldChID, c.userID)) + // Audience = broadcastVoiceEvent's (READ ∪ still-in-the-room) plus the + // leaver themselves. The union of the room's remaining participants is + // what broadcastVoiceEvent provides and must be kept: voice membership is + // gated on CONNECT_VOICE alone, so a participant without READ would + // otherwise miss the departure and keep a stale E2EE key holder. The extra + // term is the leaver: the caller has already cleared their client voice + // state, so that union can no longer see them, yet for a server-initiated + // eviction (revocation sweep, moderator kick/move, token-refresh refusal) + // this voice_leave IS their only teardown signal. Mirrors + // CleanupVoiceForChannel, which appends the evicted participants for + // exactly the same reason. + audience := h.channelReadAudience(ctx, oldChID) + seen := make(map[int64]struct{}, len(audience)+1) + for _, uid := range audience { + seen[uid] = struct{}{} + } + h.mu.RLock() + for uid, other := range h.clients { + if _, ok := seen[uid]; !ok && other.getVoiceChID() == oldChID { + seen[uid] = struct{}{} + audience = append(audience, uid) + } + } + h.mu.RUnlock() + if _, ok := seen[c.userID]; !ok { + audience = append(audience, c.userID) + } + h.broadcastChannelScopedTo(oldChID, buildVoiceLeave(oldChID, c.userID), audience, "voice event") // Re-elect key holder now that this user has left the channel. h.updateKeyHolder(oldChID) diff --git a/Server/ws/voice_moderation.go b/Server/ws/voice_moderation.go index 21dd9697..7c895ad6 100644 --- a/Server/ws/voice_moderation.go +++ b/Server/ws/voice_moderation.go @@ -132,6 +132,32 @@ func requireTargetInChannel(state *db.VoiceState, channelID int64) *Result { return &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in that voice channel"}} } +// voiceChannelDisconnector is the channel-scoped form of +// VoiceModerator.DisconnectFromVoice. The bare interface method takes no +// channel, so it evicts the target from whatever channel their live connection +// is in at that instant — not the channel voiceModTarget authorized against. +// A channel switch on the target's own read-pump goroutine, concurrent with +// the moderator's DB round trips, therefore redirects a kick or a move onto a +// channel that was never checked, up to and including a DM call the actor is +// not a participant of (the case voiceModTarget's IsDMParticipant guard +// exists to refuse). +// +// Widening VoiceModerator itself lives in deps.go; until then *Hub also +// satisfies this optional extension and disconnectFromVoiceIn prefers it, +// falling back to the unscoped method for any other implementation. +type voiceChannelDisconnector interface { + DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool +} + +// disconnectFromVoiceIn evicts targetID from channelID, reporting false when +// the target has no connection on this node or has already left that channel. +func disconnectFromVoiceIn(ctx context.Context, mod VoiceModerator, targetID, channelID int64) bool { + if scoped, ok := mod.(voiceChannelDisconnector); ok { + return scoped.DisconnectFromVoiceInChannel(ctx, targetID, channelID) + } + return mod.DisconnectFromVoice(ctx, targetID) +} + // handleVoiceModMuteV2 processes a voice_mod_mute command. The DB row is the // authority for the UI; the SFU mute is what makes it more than cosmetic, so a // LiveKit failure is logged but does not fail the action — the persisted @@ -203,6 +229,19 @@ func handleVoiceModDeafenV2(ctx context.Context, cmd Command, info ClientInfo, d // undeafen — accepted as the simplest correct behavior given the schema. if err := d.DB.SetVoiceServerMute(ctx, c.TargetID(), c.Deafened()); err != nil { slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerMute", "err", err, "target_id", c.TargetID()) + // The deafen write above already committed as its own statement (no + // transaction spans the two — a single UPDATE covering both columns + // needs a db-change; see cross_batch). Best-effort undo it rather + // than leave server_deafened=1 with server_muted=0: that combination + // is not SFU-muted yet still refuses the target's own undeafen + // (refuseIfServerSilenced), for a deafen nobody was ever told about. + // Detached from ctx — the cancellation that most likely caused the + // failure above (the moderator's socket dropping mid-request) must + // not also abort the rollback. + if compErr := d.DB.SetVoiceServerDeafen(context.WithoutCancel(ctx), c.TargetID(), !c.Deafened()); compErr != nil { + slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerDeafen rollback failed", + "err", compErr, "target_id", c.TargetID()) + } return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server deafen"}} } if d.Mod != nil { @@ -282,9 +321,11 @@ func handleVoiceModMoveV2(ctx context.Context, cmd Command, info ClientInfo, dep if d.Mod == nil { return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}} } - if !d.Mod.DisconnectFromVoice(ctx, c.TargetID()) { + if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) { // No live connection on this node — the voice_states row is a ghost the - // sweeper owns, and there is nobody to send voice_moved to. + // sweeper owns, and there is nobody to send voice_moved to — or the + // target left the checked channel while this handler was deciding, in + // which case the move must not follow them. return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}} } d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID())) @@ -318,7 +359,9 @@ func handleVoiceModKickV2(ctx context.Context, cmd Command, info ClientInfo, dep if d.Mod == nil { return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}} } - if !d.Mod.DisconnectFromVoice(ctx, c.TargetID()) { + // Scoped to the channel the gate above authorized: a target who switched + // channels mid-decision must not be kicked out of the new one. + if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) { return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}} } d.Mod.SendToUser(c.TargetID(), @@ -376,3 +419,19 @@ func (h *Hub) DisconnectFromVoice(ctx context.Context, userID int64) bool { h.handleVoiceLeave(ctx, c) return true } + +// DisconnectFromVoiceInChannel is DisconnectFromVoice conditioned on the +// channel the caller authorized against, satisfying voiceChannelDisconnector. +// The comparison and the clear happen together under the client's voiceMu +// (handleVoiceLeaveIfStillIn -> clearVoiceStateIfMatch), so a channel switch +// committed on the target's own goroutine after the moderator's checks either +// loses the race outright or is left untouched — never evicted in place of the +// channel that was checked. Reports false in both of those cases, which the +// callers already treat as "user is not connected". +func (h *Hub) DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool { + c := h.GetClient(userID) + if c == nil { + return false + } + return h.handleVoiceLeaveIfStillIn(ctx, c, channelID) +} diff --git a/Server/ws/voice_moderation_test.go b/Server/ws/voice_moderation_test.go index 9bb2fcb4..a4ecf1ca 100644 --- a/Server/ws/voice_moderation_test.go +++ b/Server/ws/voice_moderation_test.go @@ -530,6 +530,46 @@ func TestVoiceMod_Move_TextChannelDestination_BadRequest(t *testing.T) { } } +// TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel locks the fix for +// v024: voiceModTarget authorizes against a DB snapshot, but the eviction ran +// through the unscoped VoiceModerator.DisconnectFromVoice, which drops the +// target from whatever channel their live connection reports at that instant. +// A channel switch committed on the target's own read-pump goroutine while the +// moderator's checks were in flight therefore redirected the kick onto a +// channel nobody authorized — up to a DM call the actor is not part of. +// +// The interleaving itself is microseconds wide and cannot be forced from a +// test, so the post-condition it produces is staged directly: the DB row (what +// the moderator authorized against) names channel A while the client's +// in-memory voice state — the only thing DisconnectFromVoice reads — already +// names channel B. The eviction must refuse rather than tear the target out of +// B. +func TestVoiceMod_Kick_EvictionIsScopedToAuthorizedChannel(t *testing.T) { + hub, database := newVoiceModHub(t) + chanA := seedVoiceChan(t, database, "vc-kick-scope-a") + chanB := seedVoiceChan(t, database, "vc-kick-scope-b") + actor := seedVoiceUserWithRole(t, database, "admin-kick-scope", 2) + target := seedVoiceUserWithRole(t, database, "member-kick-scope", 4) + + targetClient, _ := joinVoice(t, hub, target, chanA) + ws.SetClientVoiceStateForTest(targetClient, chanB, "join-token-b") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanA, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModKickMsg(target.ID)) + + if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" { + t.Fatalf("error code = %q, want VOICE_ERROR", code) + } + if got := ws.GetClientVoiceChIDForTest(targetClient); got != chanB { + t.Errorf("target voice channel = %d after a kick authorized for channel %d, want %d (the newer membership must survive)", + got, chanA, chanB) + } +} + // ─── self-service controls under a server mute ─────────────────────────────── func TestVoiceMute_SelfUnmuteWhileServerMuted_Refused(t *testing.T) { diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 2e6439f6..a6029757 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -673,6 +673,141 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { } } +// TestServeWS_ReplayFallback_PreservesVoiceState verifies the replay-FAILURE +// path: a reconnect whose last_seq the ring buffer cannot serve (seq far ahead, +// e.g. after a server restart reset the counter) falls back to +// handleFreshConnect with lastSeq > 0 preserved. registerNow then transfers the +// old connection's live voice state into the new client, so the fresh-connect +// stale-voice cleanup must NOT delete the DB row (or remove the LiveKit +// participant, whose removal token is the very JoinedAt being transferred) — +// otherwise the user is "in voice" on the hub only: voice_join bounces off +// ALREADY_JOINED and sweepStaleVoiceStates never heals memory-without-row. +func TestServeWS_ReplayFallback_PreservesVoiceState(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter, nil) + go hub.Run() + defer hub.Stop() + + userID, err := database.CreateUser(context.Background(), "ws-voice-fallback", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + chID, err := database.CreateChannel(context.Background(), "voice-fallback", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(handler) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dialAndAuth := func(lastSeq uint64) *websocket.Conn { + t.Helper() + conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil) + if dialResp != nil && dialResp.Body != nil { + dialResp.Body.Close() + } + if dialErr != nil { + t.Fatalf("websocket.Dial: %v", dialErr) + } + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]any{"token": token, "last_seq": lastSeq}, + } + raw, marshalErr := json.Marshal(authMsg) + if marshalErr != nil { + t.Fatalf("marshal auth: %v", marshalErr) + } + if writeErr := conn.Write(ctx, websocket.MessageText, raw); writeErr != nil { + t.Fatalf("write auth: %v", writeErr) + } + for i := range 2 { + if _, _, readErr := conn.Read(ctx); readErr != nil { + t.Fatalf("read handshake message %d: %v", i, readErr) + } + } + return conn + } + + // First connection: fresh, then put the user in voice (DB + in-memory). + conn1 := dialAndAuth(0) + defer func() { _ = conn1.Close(websocket.StatusNormalClosure, "") }() + + var originalClient *ws.Client + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + originalClient = hub.GetClient(userID) + if originalClient != nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if originalClient == nil { + t.Fatal("expected first client to be registered") + } + + if err := database.JoinVoiceChannel(context.Background(), userID, chID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + vsBefore, err := database.GetVoiceState(context.Background(), userID) + if err != nil { + t.Fatalf("GetVoiceState(before reconnect): %v", err) + } + if vsBefore == nil { + t.Fatal("expected voice state row after JoinVoiceChannel") + } + ws.SetClientVoiceStateForTest(originalClient, chID, vsBefore.JoinedAt) + + // Second connection: last_seq far ahead of anything the buffer holds — + // replay fails, handleFreshConnect runs with lastSeq > 0, and registerNow + // transfers the old connection's voice state. + conn2 := dialAndAuth(999) + defer func() { _ = conn2.Close(websocket.StatusNormalClosure, "") }() + + var replacementClient *ws.Client + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + replacementClient = hub.GetClient(userID) + if replacementClient != nil && replacementClient != originalClient { + break + } + time.Sleep(20 * time.Millisecond) + } + if replacementClient == nil || replacementClient == originalClient { + t.Fatal("expected replacement client to be registered") + } + + // The transferred in-memory state and the DB row must stay consistent: + // either both present (transfer honored) or both gone — never memory-only. + if got := ws.GetClientVoiceChIDForTest(replacementClient); got != chID { + t.Fatalf("replacement client voiceChID = %d, want %d", got, chID) + } + vs, vsErr := database.GetVoiceState(context.Background(), userID) + if vsErr != nil { + t.Fatalf("GetVoiceState: %v", vsErr) + } + if vs == nil { + t.Fatal("replay fallback: DB voice_state row was deleted while registerNow transferred the in-memory state") + } + if vs.ChannelID != chID { + t.Fatalf("replay fallback: DB voice_state channel_id = %d, want %d", vs.ChannelID, chID) + } +} + // TestServeWS_Reconnect_AuthorizedVoiceClientKeepsChannelStream verifies the // authorized half of the voice-subscription gate end to end: the reconnect // handshake passes the user's READ_MESSAGES set to registerNow, so a user who diff --git a/docs/api.md b/docs/api.md index 24a80bda..287a2fcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,11 +16,19 @@ All authenticated endpoints require a session token delivered via the `Authoriza ### Middleware Stack (all routes) -1. **RequestID** -- assigns a unique `X-Request-Id` response header. -2. **Recoverer** -- catches panics and returns 500. -3. **Request Logger** -- structured logging of method, path, status, duration. -4. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. -5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit). +In mount order (`Server/api/router.go`): + +1. **boundRequestID** -- drops an oversized (>128 bytes) or non-printable client-supplied `X-Request-Id` before chi adopts it. +2. **RequestID** (chi) -- assigns the request ID used in logs. +3. **setRequestIDHeader** -- echoes the request ID into the `X-Request-Id` response header. +4. **Recoverer** -- catches panics, logs them through `slog` with a stack capture, returns 500. +5. **Request Logger** -- structured logging of method, path, status, duration. +6. **Telemetry HTTP middleware** -- OpenTelemetry tracing; a no-op unless the server was built with `-tags otel` and telemetry is enabled. +7. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. +8. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit). +9. **Coraza WAF** (optional) -- OWASP Core Rule Set request filtering, mounted only when `server.waf_enabled: true` (see `docs/server-configuration.md`). + +Note: chi's `middleware.RealIP` is deliberately **not** used -- client IPs are resolved from `X-Forwarded-For` only when the peer is listed in `server.trusted_proxies`. --- @@ -117,7 +125,7 @@ See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table Authenticate with username and password. **Auth:** None (public) -**Rate limit:** 60 requests/minute per IP. After 10 consecutive failures from the same IP, the IP is locked out for 15 minutes. +**Rate limit:** 5 requests/minute per IP. After 10 failed attempts within 15 minutes from the same IP, the IP is locked out for 15 minutes. Independently, 10 failed attempts against the same username (from any IP) lock that account out for 15 minutes. Lockouts are persisted to the database and survive server restarts. #### Request @@ -1470,10 +1478,24 @@ Runtime server metrics. Restricted to admin-allowed CIDRs. "heap_sys_mb": 24.0, "num_gc": 156, "connected_users": 8, + "voice_sessions": 2, + "broadcast_drops": 0, "livekit_healthy": true } ``` +`voice_sessions` is the number of active voice connections; `broadcast_drops` +is the cumulative count of WebSocket events dropped because a client send +queue was full. `livekit_healthy` is omitted when no LiveKit health check is +wired. + +### GET /metrics (Prometheus) + +A Prometheus text-format exporter is mounted at `/metrics` **only** when the +server was built with `-tags otel` and `telemetry.exporter` is set to +`prometheus`. It is admin-IP-restricted like the JSON endpoint. In the default +build the route does not exist (404). + --- ## Admin API Authorization @@ -1536,6 +1558,512 @@ Every route still re-checks its bit server-side. --- +## First-Run Setup + +### GET /admin/api/setup/status + +Reports whether initial setup is needed (no users exist yet). + +**Auth:** None (public). After the first user exists, the response reveals +nothing about the configuration. + +#### Response 200 OK + +```json +{ + "needs_setup": true, + "defaults": { + "server_name": "OwnCord", + "motd": "Welcome!", + "registration_open": false, + "port": 8443, + "tls_mode": "self-signed", + "tls_domain": "", + "upload_max_size_mb": 100, + "voice_quality": "medium", + "voice_auto_download": true + } +} +``` + +`defaults` (wizard prefill from the running config and settings table) is +present only while `needs_setup` is `true`. + +--- + +### POST /admin/api/setup + +Create the first (Owner) account, optionally applying first-run wizard +configuration. Only functional while no users exist; afterwards it returns an +error. + +**Auth:** None (public) +**Rate limit:** 5 requests/minute per IP + +#### Request + +```json +{ + "username": "owner", + "password": "MyStr0ng!Pass", + "wizard": { + "server_name": "My Server", + "motd": "Welcome!", + "registration_open": false, + "port": 8443, + "tls_mode": "self-signed", + "tls_domain": "", + "upload_max_size_mb": 100, + "voice_quality": "medium", + "voice_auto_download": true + } +} +``` + +All `wizard` fields are optional; `server_name`, `motd` and +`registration_open` are stored in the settings table (live), the rest are +written back to `config.yaml` (consumed at startup). + +#### Response 200 OK + +```json +{ + "token": "raw-session-token", + "user_id": 1, + "username": "owner", + "invite_code": "abc123def", + "restart_required": false, + "restart_url": "", + "warnings": [] +} +``` + +`restart_required` is `true` when wizard values that are only read at startup +(port, TLS) differ from the running config; the server restarts itself right +after responding, and `restart_url` is where the admin panel will be reachable +afterwards. `warnings` lists non-fatal problems (e.g. `config.yaml` not +writable) — the account exists whenever this response is returned. + +--- + +## Server Stats & User Administration + +### GET /admin/api/stats + +Aggregate counts for the admin dashboard. + +**Auth:** Admin perimeter + +#### Response 200 OK + +```json +{ + "user_count": 12, + "message_count": 4821, + "channel_count": 9, + "invite_count": 2, + "db_size_bytes": 1048576, + "online_count": 3 +} +``` + +--- + +### GET /admin/api/users + +List all users with role and ban state. + +**Auth:** Admin perimeter +**Query params:** `limit` (default 50, min 1), `offset` (default 0) + +#### Response 200 OK + +Array of: + +| Field | Type | Notes | +| ----- | ---- | ----- | +| `id` | int | | +| `username` | string | | +| `avatar` | string? | omitted when unset | +| `role_id` | int | | +| `role_name` | string | | +| `status` | string | presence status | +| `created_at` | string | | +| `last_seen` | string? | omitted when never seen | +| `banned` | bool | | +| `ban_reason` | string? | omitted when unset | +| `ban_expires` | string? | omitted for permanent bans | + +Password hashes and TOTP secrets are never included. + +--- + +### PATCH /admin/api/users/{id} + +Change a user's role and/or ban state. Both actions route through the +moderation service, which enforces the required bit (`MANAGE_ROLES` for +`role_id`, `BAN_MEMBERS` for `banned`), the role hierarchy, and writes the +audit row. + +**Auth:** Admin perimeter + per-action bit (see above) + +#### Request + +```json +{ + "role_id": 3, + "banned": true, + "ban_reason": "spam", + "ban_duration_hours": 24 +} +``` + +All fields optional. `ban_duration_hours` makes the ban temporary (1–8760; +omitted or `0` = permanent) and is only meaningful with `banned: true`. + +#### Response 200 OK -- the updated user (same shape as the list entry). + +#### Errors + +| Status | Code | Cause | +| ------ | ---- | ----- | +| 400 | `BAD_REQUEST` | Invalid id/body, `ban_duration_hours` out of range, or attempting to modify your own account | +| 403 | `FORBIDDEN` | Missing bit, or the actor does not outrank the target | +| 404 | `NOT_FOUND` | User not found | + +--- + +### DELETE /admin/api/users/{id}/sessions + +Force-logout: revoke every session of the target user. The hierarchy rule +(actor outranks target) is enforced in the moderation service and the action +is audited. + +**Auth:** `KICK_MEMBERS` + +#### Response 204 No Content + +--- + +## Audit Log + +### GET /admin/api/audit-log + +Read the audit trail, newest first. + +**Auth:** `VIEW_AUDIT_LOG` +**Query params:** `limit` (default 50, min 1), `offset` (default 0) + +#### Response 200 OK + +Array of: + +```json +{ + "id": 991, + "actor_id": 1, + "actor_name": "owner", + "action": "user_ban", + "target_type": "user", + "target_id": 7, + "detail": "spam", + "created_at": "2026-08-04T12:00:00Z" +} +``` + +--- + +## Server Settings + +### GET /admin/api/settings + +**Auth:** `MANAGE_SERVER` + +Returns the settings table as a flat string map, e.g.: + +```json +{ + "server_name": "My Server", + "motd": "Welcome!", + "registration_open": "1", + "require_2fa": "0" +} +``` + +--- + +### PATCH /admin/api/settings + +Update settings. Keys are validated against a whitelist before anything is +written, and all updates are applied in one transaction; each change is +audited as `setting_change`. + +**Auth:** `MANAGE_SERVER` + +#### Request + +A flat map of key → string value. Allowed keys: `server_name`, `server_icon`, +`motd`, `max_upload_bytes`, `voice_quality`, `require_2fa`, +`registration_open`, `backup_schedule`, `backup_retention`. Boolean settings +accept `1/0/true/false` and are normalized to `1`/`0`. + +Enabling `require_2fa` is refused unless registration is closed **and** every +user has TOTP enabled. + +#### Response 200 OK -- the full settings map after the update. + +#### Errors + +| Status | Code | Cause | +| ------ | ---- | ----- | +| 400 | `BAD_REQUEST` | Unknown key, invalid boolean, or `require_2fa` preconditions not met | + +--- + +## API Tokens + +Owner-only: minting a long-lived bearer credential over the network is the one +admin action that, via a hijacked session, would outlive a password change and +bulk logout (API tokens deliberately live outside the session table). These +routes are the HTTP equivalent of the `server token create|list|revoke` CLI. + +### GET /admin/api/tokens + +**Auth:** Owner role + +#### Response 200 OK + +Array of: + +```json +{ + "id": 1, + "user_id": 1, + "username": "owner", + "label": "ci-bot", + "created_at": "2026-08-01T10:00:00Z", + "last_used": null, + "expires_at": null, + "revoked_at": null +} +``` + +Token hashes are never returned. + +--- + +### POST /admin/api/tokens + +**Auth:** Owner role + +#### Request + +```json +{ + "label": "ci-bot", + "username": "", + "expires_hours": 0 +} +``` + +`label` is required. Empty `username` binds the token to the owner account; +`expires_hours: 0` means never expires. + +#### Response 201 Created + +```json +{ + "id": 2, + "token": "raw-api-token", + "label": "ci-bot", + "user": "owner" +} +``` + +The raw token is shown exactly once and is never recoverable. + +--- + +### DELETE /admin/api/tokens/{id} + +**Auth:** Owner role + +#### Response 204 No Content + +`404 NOT_FOUND` if there is no *active* token with that id. + +--- + +## Backups + +All backup routes are Owner-only. Backups are SQLite snapshots (`VACUUM INTO`) +stored under `data/backups/`. + +### POST /admin/api/backup + +Create a backup named `chatserver_<UTC timestamp>.db`. + +**Auth:** Owner role + +#### Response 200 OK + +```json +{ + "path": "chatserver_20260804_120000.db", + "created": "20260804_120000" +} +``` + +--- + +### GET /admin/api/backups + +List backups, newest first. + +**Auth:** Owner role + +#### Response 200 OK + +```json +[{ "name": "chatserver_20260804_120000.db", "size": 1048576, "date": "2026-08-04T12:00:00Z" }] +``` + +--- + +### DELETE /admin/api/backups/{name} + +**Auth:** Owner role + +`name` is validated against path traversal. Returns `204 No Content`, or +`404 NOT_FOUND` if the file does not exist. + +--- + +### POST /admin/api/backups/{name}/restore + +Restore the database from a backup. The server first writes a +`pre_restore_<timestamp>.db` safety backup (the restore is aborted if that +fails), broadcasts a `server_restart` to connected clients, checkpoints and +closes the database, copies the backup over it, responds, and then restarts +itself. + +**Auth:** Owner role + +#### Response 200 OK + +```json +{ + "message": "database restored — server restarting", + "backup": "chatserver_20260804_120000.db" +} +``` + +--- + +## Server Updates + +Owner-only self-update from GitHub Releases (minisign/Ed25519-verified; see +`docs/security.md`). + +### GET /admin/api/updates + +**Auth:** Owner role + +#### Response 200 OK + +```json +{ + "current": "v1.2.0-alpha.1", + "latest": "v1.2.0", + "update_available": true, + "required_assets_present": true, + "release_url": "…", + "download_url": "…", + "checksum_url": "…", + "signature_url": "…", + "manifest_url": "…", + "manifest_signature_url": "…", + "release_notes": "…", + "can_apply": true +} +``` + +`can_apply` is `false` in container deployments (detected via +`OWNCORD_CONTAINER`, which the shipped Dockerfile sets, or the engine marker +files): checking still works, but `POST /updates/apply` will refuse — the +admin SPA replaces the apply button with an image-upgrade note. + +#### Errors + +| Status | Code | Cause | +| ------ | ---- | ----- | +| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured | +| 502 | `UPDATE_CHECK_FAILED` | GitHub API failure | + +--- + +### POST /admin/api/updates/apply + +Download, verify and apply the latest release. On success the server responds +first, then broadcasts a restart notice, swaps the binary (with staged-hash +re-verification against TOCTOU swaps), spawns the new process and shuts down. + +**Auth:** Owner role + +#### Response 200 OK + +```json +{ "status": "applying", "version": "v1.2.0" } +``` + +#### Errors + +| Status | Code | Cause | +| ------ | ---- | ----- | +| 503 | `CONTAINER_DEPLOYMENT` | Container deployment — the binary is image content; upgrade by pulling the new image (opt back in with `OWNCORD_CONTAINER=0` if the binary is bind-mounted) | +| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured | +| 409 | `NO_UPDATE` | Already up to date | +| 502 | `UPDATE_CHECK_FAILED` / `MISSING_ASSETS` / `DOWNLOAD_FAILED` | Check, asset or download/verification failure | + +--- + +## Server Logs (SSE) + +Streaming the server log requires two steps because `EventSource` cannot send +an `Authorization` header. + +### POST /admin/api/logs/ticket + +Issue a single-use ticket (30 s TTL) bound to the calling bearer credential. + +**Auth:** `ADMINISTRATOR` + +#### Response 200 OK + +```json +{ "ticket": "64-hex-chars" } +``` + +--- + +### GET /admin/api/logs/stream?ticket={ticket} + +Server-Sent Events stream of structured log records: on connect the in-memory +ring buffer (capacity 2000) is replayed as backfill, then new entries stream +live, with a keepalive every 15 s. The ticket is consumed on connect; the +`ADMINISTRATOR` bit is re-checked throughout the stream, and revoking the +underlying session or API token (or banning the user) mid-stream cuts it. + +**Auth:** single-use ticket (from `POST /admin/api/logs/ticket`) + +Each event's data is one JSON record: + +```json +{ "ts": "2026-08-04T12:00:00Z", "level": "INFO", "msg": "…", "source": "…", "attrs": "…" } +``` + +--- + ## Role Management Create, edit, delete and reorder roles. The whole group requires diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 63f441d3..e7924285 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,7 +1,9 @@ # OwnCord Architecture Blueprints -**Verified against:** commit `ddc49f0`, 2026-07-19 -**Companion audit:** [docs/audit-2026-07-19.md](../audit-2026-07-19.md) +**Verified against:** commit `5630aa1`, 2026-08-04 +**Companion audits:** [docs/audit-2026-08-04-docs-and-coverage.md](../audit-2026-08-04-docs-and-coverage.md) (docs & coverage), +[docs/audit-2026-08-04.md](../audit-2026-08-04.md) (security), +[docs/audit-2026-07-19.md](../audit-2026-07-19.md) (architecture) This directory is the curated architectural map of OwnCord — the "blueprints" for the whole system. Every diagram is a Mermaid fenced block (GitHub renders these @@ -14,7 +16,7 @@ natively) followed by a prose explanation and a **Source of truth** file list. | [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints | | [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain | | [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, typed dispatch | -| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 23 tables from migrations 001–015, grouped by domain | +| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001–028, grouped by domain | | [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay | | [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) | | [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure | @@ -44,6 +46,7 @@ in the dated audit reports, which are point-in-time snapshots by design. - `docs/api.md`, `docs/protocol.md`, `docs/schema.md` are the *reference specs* (request/response shapes, wire formats, DDL). These blueprints describe *structure and flow*, not payload shapes. Known drift between the specs and - the code is catalogued in [audit-2026-07-19.md §2](../audit-2026-07-19.md). -- `docs/client-architecture.md` predates the abandonment of the Solid.js - migration; [client.md](client.md) reflects the current state. + the code is catalogued in the dated audit reports (latest: + [audit-2026-08-04-docs-and-coverage.md](../audit-2026-08-04-docs-and-coverage.md)). +- `docs/client-architecture.md` is a redirect stub kept for old links; + [client.md](client.md) is the client architecture document. diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 4f56707e..0b411e3d 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -1,19 +1,19 @@ # Client Architecture (Tauri) -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 -Desktop client built on Tauri v2: a TypeScript webview (~31.7k LOC, vanilla TS — -no UI framework) plus ~2.3k LOC of Rust commands. State lives in a hand-rolled -reactive store (`src/lib/store.ts`: immutable updates, microtask-batched -notifications, selector subscriptions). Components are factory functions -returning `{ element, mount, destroy }` built with the `@lib/dom` helpers; a -2-page state machine (`src/lib/router.ts`) switches between the Connect and -Main pages. +Desktop client built on Tauri v2: a TypeScript webview (~42k LOC, vanilla TS — +no UI framework) plus ~4.7k LOC of Rust across 16 modules. State lives in a +hand-rolled reactive store (`src/lib/store.ts`: immutable updates, +microtask-batched notifications, selector subscriptions). Components are +factory functions returning `{ element, mount, destroy }` built with the +`@lib/dom` helpers; a 2-page state machine (`src/lib/router.ts`) switches +between the Connect and Main pages. -> `docs/client-architecture.md` still describes a SolidJS-based client. The -> Solid migration was **abandoned** (per CHANGELOG); only a 154-LOC beachhead -> remains under `src/components/solid/`. This document reflects the actual -> state. +> `docs/client-architecture.md` is a 15-line redirect stub kept for old links; +> this document is the client architecture reference. The abandoned SolidJS +> beachhead that used to live under `src/components/solid/` was fully removed +> (audit A-2026-07-12, closed 2026-07-19). ## D7 — Module map @@ -24,37 +24,40 @@ flowchart TB end subgraph rust ["Rust (src-tauri)"] - WSP["ws_proxy.rs<br/>WSS + TOFU cert pinning"] - LKP["livekit_proxy.rs<br/>loopback TLS tunnel"] - CRED["credentials.rs<br/>OS keychain"] + TOFU["tofu.rs<br/>shared TOFU core:<br/>3 rustls verifiers + pure decide"] + WSP["ws_proxy.rs<br/>WSS proxy"] + HTP["http_proxy.rs<br/>loopback TCP→TLS REST tunnel"] + LKP["livekit_proxy.rs<br/>loopback TLS tunnel (pin required)"] + CRED["credentials.rs + secret_store.rs<br/>OS keychain + verified fallback"] PTT["ptt.rs<br/>push-to-talk polling"] UPDC["update_commands.rs<br/>self-hosted updater"] - SET["commands.rs<br/>settings store (key allowlist)"] + SET["commands.rs<br/>settings + cert/identity pin stores"] end subgraph comm ["Communication layer (src/lib)"] - API["api.ts<br/>REST client (tauri-plugin-http,<br/>allowSelfSigned=true)"] - WSC["ws.ts<br/>reconnect w/ backoff, seq replay,<br/>generation counters"] - DISP["dispatcher.ts<br/>~30 msg types → store mutators"] - LKS["livekitSession.ts (1.7k LOC)<br/>voice state machine + E2EE"] + API["api.ts<br/>REST client via httpProxy.ts<br/>(TOFU-pinned Rust tunnel)"] + WSC["ws.ts<br/>reconnect w/ backoff, seq replay,<br/>generation counters, cert-tofu events"] + DISP["dispatcher.ts<br/>34 msg types → store mutators"] + LKS["livekitSession.ts (1.4k LOC)<br/>voice state machine"] + LKE["livekitE2EE.ts<br/>key-holder election, room-key<br/>wrap/unwrap, peer verification"] end - subgraph state ["Stores (8 singletons)"] + subgraph state ["Stores (9 singletons)"] AUTH2["auth"] - CHAN["channels"] + CHAN["channels<br/>(incl. roles)"] MSG["messages"] MEM["members"] VOICE["voice"] DM["dm"] - ROLES["roles"] + BLK["blocks"] + EMO["emoji"] UIS["ui"] end subgraph ui ["UI (imperative DOM)"] CP["ConnectPage<br/>profiles + health polling"] - MP["MainPage<br/>SidebarArea / ChatArea /<br/>controllers"] - COMP["~30 component families<br/>message-list, settings tabs,<br/>voice widgets, overlays"] - SOLID["components/solid/<br/>abandoned beachhead"] + MP["MainPage<br/>SidebarArea / ChatArea /<br/>15 controllers"] + COMP["~60 component files<br/>message-list, settings tabs,<br/>voice widgets, overlays"] end MAIN --> CP @@ -62,38 +65,46 @@ flowchart TB MAIN --> API MAIN --> WSC WSC --> WSP + API --> HTP + WSP --> TOFU + HTP --> TOFU + LKP --> TOFU WSC --> DISP - DISP --> AUTH2 & CHAN & MSG & MEM & VOICE & DM & ROLES & UIS + DISP --> AUTH2 & CHAN & MSG & MEM & VOICE & DM & BLK & EMO & UIS state --> ui LKS --> LKP + LKS --> LKE VOICE --> LKS MP --> COMP - API -.->|"no cert pinning<br/>(unlike WS path)"| SRV["Go server"] - WSP --> SRV + WSP --> SRV["Go server"] + HTP --> SRV CRED -.-> MAIN UPDC -.-> MAIN %% cross-store coupling (audit finding) AUTH2 -.->|clearAuth → leaveVoice| VOICE VOICE -.-> MEM - - classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6 - class SOLID dead ``` **What this shows.** Data flows one way in the happy path: WS frame → Rust `ws_proxy` → `ws.ts` → `dispatcher.ts` → store mutators → subscribed components -re-render. The dashed edges mark the audit findings: the HTTP path accepts any -certificate (`allowSelfSigned` hardcoded, no TOFU pinning — unlike the WS and -LiveKit paths, which pin fingerprints in Rust), the stores cross-import each -other (auth→voice→members), and the Solid beachhead is dead weight. +re-render. All three network paths — WebSocket, REST, and LiveKit — terminate +TLS inside Rust proxies that share one TOFU core (`tofu.rs`): the WS and HTTP +proxies use a capture-then-decide verifier, and the LiveKit proxy refuses to +start without an existing pin. Deciding never writes a pin — a first +connection is *rejected* and surfaced to the user as a blocking trust prompt +before any pin is stored (the former auto-pin-on-first-use behavior was +removed in the 2026-07-22 security remediation). The remaining dashed edges +mark cross-store coupling (auth→voice→members) — known structural debt, not +yet scheduled. ### Key mechanisms | Concern | Where | How | |---------|-------|-----| | Reconnect | `src/lib/ws.ts` | Exponential backoff (cap 30s), heartbeat 30s, `last_seq` replay + bounded dedup set, generation counter invalidates stale listeners | -| Cert trust | `src-tauri/src/ws_proxy.rs` | TOFU: first fingerprint pinned per host (`certs.json`); mismatch → modal (`CertMismatchModal`) | +| Cert trust | `src-tauri/src/tofu.rs` (shared by `ws_proxy.rs`, `http_proxy.rs`, `livekit_proxy.rs`) | TOFU with explicit consent: fingerprints stored per host in `certs.json`, but *deciding never writes a pin* — first use and mismatch both reject the connection and emit a `cert-tofu` event; the TS side shows a blocking modal (`CertMismatchModal.ts`) and only an explicit Accept stores/updates the pin. The updater uses a fourth, host-scoped verifier (pin for the OwnCord host, WebPKI for GitHub). | +| Voice E2EE identity | `src/lib/identity.ts` + `src-tauri/src/commands.rs` | Long-term ECDSA identity key in the OS keyring (`identity:{host}`); peer identity keys pinned in `identity_pins.json`; changed peer key → blocking identity-mismatch modal with safety-number comparison | | Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS | | Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels | | HTTP capability | `src-tauri/capabilities/default.json` | `http:allow-fetch` is the only URL-scoped identifier (the other two `fetch_*` commands take a validated `ResourceId`); allows `https://*` + `http://127.0.0.1:*`, denies https loopback. Wildcard is required by link previews — see [docs/plans/tauri-capability-narrowing.md](../plans/tauri-capability-narrowing.md) | @@ -104,11 +115,13 @@ other (auth→voice→members), and the Solid beachhead is dead weight. ### Quality tooling -157 test files (~63k LOC — about 2× the source): Vitest unit + integration, -Playwright E2E (web and native Tauri suites), Stryker mutation testing, oxlint + -type-checked ESLint, Prettier, Knip, strict `tsc`. The client unit suite is -green and blocking; it must pass 100% before a push (audit item A-2026-07-04, -closed 2026-07-20). +224 test files (~83k LOC — about 2× the source): Vitest unit + integration +(70% coverage gate, blocking in CI), Playwright E2E (web suite in CI — +non-blocking full run plus a blocking `@parity` subset — and a native Tauri +suite that is deliberately not wired to CI), Stryker mutation testing +(manual-only), oxlint + type-checked ESLint, Prettier, Knip (non-blocking), +strict `tsc`. Rust: 84 `cargo test --lib` tests across 10 of the 16 modules, +blocking in CI together with `cargo clippy -D warnings`. **Source of truth:** `src/main.ts`, `src/lib/dispatcher.ts`, `src/lib/ws.ts`, `src/lib/api.ts`, `src/lib/store.ts`, `src/stores/*.store.ts`, diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 15bc60f8..00fe97d4 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -1,26 +1,29 @@ # Data Model -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 -The canonical schema is the ordered migration set `Server/migrations/001–015` +The canonical schema is the ordered migration set `Server/migrations/001–029` (embedded via `go:embed`, applied by the custom runner in `Server/db/migrate.go`, tracked in the `schema_versions` table). SQLite is the only supported engine — `Server/main.go` rejects any other `database.type` at startup. ## D5 — Entity-relationship overview -All 23 application tables, grouped by domain. Junction/leaf detail columns are -elided; the goal is the relationship graph, not full DDL (see `docs/schema.md` -for DDL — note it is currently 6 migrations behind, see -[audit-2026-07-19.md §2](../audit-2026-07-19.md)). +All 25 application tables, grouped by domain. Junction/leaf detail columns are +elided; the goal is the relationship graph, not full DDL — see +[`docs/schema.md`](../schema.md) for per-table DDL (current through +migration 028). ```mermaid erDiagram %% ── Identity & access ── roles ||--o{ users : "role_id" users ||--o{ sessions : "user_id" + users ||--o{ api_tokens : "user_id" roles ||--o{ channel_overrides : "role_id" channels ||--o{ channel_overrides : "channel_id" + users ||--o{ channel_user_overrides : "user_id" + channels ||--o{ channel_user_overrides : "channel_id" users ||--o{ user_blocks : "blocker_id / blocked_id" users ||--o{ invites : "created_by / redeemed_by" @@ -56,7 +59,6 @@ erDiagram login_attempts rate_lockouts emoji - sounds users { int id PK @@ -74,7 +76,8 @@ erDiagram channels { int id PK string name - string type "text | voice | dm (trigger-enforced)" + string type "text | voice | announcement | dm (trigger-enforced)" + bool is_group "028: marks a group DM" } messages { int id PK @@ -99,13 +102,13 @@ erDiagram | Domain | Tables | Notes | |--------|--------|-------| -| Identity & access | `roles`, `users`, `sessions`, `channel_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`. `rate_lockouts` (011) persists rate-limiter lockouts across restarts. | +| Identity & access | `roles`, `users`, `sessions`, `api_tokens`, `channel_overrides`, `channel_user_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. `api_tokens` (018) are long-lived bearer credentials (owner-minted, hash-stored) that deliberately live outside the session table. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`, with `channel_user_overrides` (024) as a per-user final layer on top of the role layer. `users` gained `identity_public_key` (017) for voice E2EE identity pinning and `display_name`/`about`/`custom_status` (027). `rate_lockouts` (011) persists rate-limiter lockouts across restarts. | | Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `message_mentions`, `emoji` | `message_mentions` (022) stores server-resolved `@username` mentions per message; `messages.mentions_everyone` flags an authorized `@everyone`/`@here`, and `read_states.mention_count` is the per-user unread badge those two drive. `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. | -| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). | +| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). `channels.is_group` (028) marks a group DM so group-ness survives participants leaving. | | Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. | | Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. | | Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. | -| Ops | `settings`, `audit_log`, `sounds` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. | +| Ops | `settings`, `audit_log` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. (The dead `sounds` table was dropped by migration 029, closing A-2026-07-13.) | ### How the schema is accessed diff --git a/docs/architecture/server.md b/docs/architecture/server.md index a7228fd7..5b18e6ec 100644 --- a/docs/architecture/server.md +++ b/docs/architecture/server.md @@ -1,11 +1,11 @@ # Server Architecture -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Single Go binary (`github.com/owncord/server`, Go 1.26). Pure-Go SQLite -(`modernc.org/sqlite`, no CGO), chi router, `nhooyr.io/websocket`, LiveKit for -voice, Wazero for plugins (build-tag gated), optional OpenTelemetry -(`-tags otel`). Roughly 29k LOC of production code and 46k LOC of tests. +(`modernc.org/sqlite`, no CGO), chi router, `github.com/coder/websocket`, +LiveKit for voice, Wazero for plugins (build-tag gated), optional OpenTelemetry +(`-tags otel`). Roughly 42k LOC of production code and 71k LOC of tests. ## D2 — Package map @@ -33,7 +33,7 @@ flowchart TB subgraph data ["Data"] DB[("db<br/>query methods (sqlc-backed),<br/>migration runner, models")] DBGEN["db/dbgen<br/>sqlc-generated queries"] - MIG["migrations<br/>001–016 embedded SQL"] + MIG["migrations<br/>001–028 embedded SQL"] end subgraph support ["Support"] @@ -75,7 +75,8 @@ type-checked query layer rather than dead generated code. Two dashed edges mark the residual seam: many REST handlers still receive a raw `*db.DB` alongside `svc`, and the `admin` package operates on `*db.DB` almost exclusively — consolidating those behind the service layer is the remaining work (audit -A-2026-07-06). See [data-model.md](data-model.md). +A-2026-07-06 — resolved for the store seam itself; the residual consolidation +is its backlog item 12). See [data-model.md](data-model.md). `api.NewRouter` (`Server/api/router.go`) is the composition root: it constructs the rate limiter, TOTP key, storage, `service.New`, the `ws.Hub`, the LiveKit diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index e90be25c..b82f2e76 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -1,6 +1,6 @@ # System Overview -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 OwnCord is a self-hosted chat stack: one Go server binary per community, a Tauri desktop client that can hold profiles for many servers (one active connection at diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md index 15882644..5eef27eb 100644 --- a/docs/architecture/ux/README.md +++ b/docs/architecture/ux/README.md @@ -1,7 +1,7 @@ # OwnCord Client UX Specification (target state) -**Verified against:** commit `da4acc5`, 2026-07-19 -**Companion:** [../client.md](../client.md) (structural module map) · [../../audit-2026-07-19.md](../../audit-2026-07-19.md) +**Verified against:** commit `5630aa1`, 2026-08-04 +**Companion:** [../client.md](../client.md) (structural module map) · [../../audit-2026-08-04-docs-and-coverage.md](../../audit-2026-08-04-docs-and-coverage.md) This directory specifies **how the Tauri client should behave** — what every UI step does, and how each view reacts to server events, permission state, and @@ -80,7 +80,8 @@ source of truth in `ui.store.connectionStatus` `onStateChange`, and read by any control that needs a live socket. > **✓ Implemented (2026-07).** `ui.store.connectionStatus` is now the single -> source of truth: `main.ts` registers the one writer +> source of truth: `main.ts` calls `wireConnectionStatus(ws)`, whose writer +> lives in `lib/dispatcher.ts` > (`ws.onStateChange` → `toConnectionStatus` → `setConnectionStatus`), mapping > the internal 5-state machine onto the 3-state status (`connecting` / > `authenticating` read as `reconnecting`, since a reconnect cycle passes @@ -126,17 +127,26 @@ detail each; this is the index. | `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo | | `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) | | `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone | +| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass | | `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | | `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | | `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | | `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove | | `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted | +| `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances | +| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji | | `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings | +| `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason | | `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators | | `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | | `server_restart` | `ui.setTransientError` | Restart banner with countdown | | `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | +`call_incoming` / `call_declined` are deliberately *not* routed through the +dispatcher: `MainPage.ts` subscribes to them directly (page-scoped listeners) +and drives the ring state machine in `lib/call-ring.ts` + +`components/IncomingCallBanner.ts`. + > **✓ Implemented (2026-07).** Error codes are no longer silently dropped for > sends: the server echoes the request id on error replies, so `SLOW_MODE`, > `FORBIDDEN`, `RATE_LIMITED`, `BAD_REQUEST`, etc. are mapped to the exact @@ -149,7 +159,7 @@ detail each; this is the index. ## 5. Error & permission reaction matrix One canonical reaction per failure class, applied everywhere. Today error -handling is per-call-site with no shared mapper (`api.ts:81-140` centralizes only +handling is per-call-site with no shared mapper (`doFetch()` in `lib/api.ts` centralizes only 401); this matrix is the target contract. | Class | Source | Target reaction | @@ -159,12 +169,12 @@ handling is per-call-site with no shared mapper (`api.ts:81-140` centralizes onl | **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect | | **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown | | **Slow-mode** | WS `SLOW_MODE` | Disable send with a live countdown in the composer; do not drop the drafted message | -| **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars, `LoginForm.ts:598`; apply everywhere) | +| **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars in the `handleFormSubmit()` catch block, `pages/connect-page/LoginForm.ts`; apply everywhere) | | **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) | | **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop | | **Transport backpressure** | WS `ws_send` "channel full" | Mark the optimistic row failed with Retry (✓ since 2026-07: `ws.onSendFailure` → dispatcher → `markSendFailed` with `NETWORK`/`OFFLINE`; id-less sends like heartbeats stay silent) | -| **Cert first-use** | Rust `cert-tofu: trusted_first_use` | 8 s informational banner (already: `main.ts:105-129`) | -| **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: `main.ts:133-164`) | +| **Cert first-use** | Rust `cert-tofu: first_use` | **Blocking trust modal** (`createCertFirstUseModal`): the Rust proxy *rejects* the first connection rather than auto-pinning; Accept stores the pin and retries, Cancel leaves the server untrusted (already: the `ws.onCertFirstUse(...)` handler in `main.ts`) | +| **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: the `ws.onCertMismatch(...)` handler in `main.ts`) | --- diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md index 4d68848d..b51bf0d0 100644 --- a/docs/architecture/ux/channels-members-dms.md +++ b/docs/architecture/ux/channels-members-dms.md @@ -1,6 +1,6 @@ # Channels, Members & Direct Messages — target UX -**Verified against:** commit `da4acc5`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Part of the [Client UX Specification](README.md). Covers the sidebar surfaces: the channel list (switch, categories, reorder, @@ -18,7 +18,7 @@ category, sorted by position. The sidebar has two modes (`ui.store.sidebarMode`) | State | Trigger | Target reaction | |-------|---------|-----------------| | `ready` | Channels loaded from `ready` | Grouped, collapsible category list | -| `empty` | Zero channels | "No channels yet" + hint (already `ChannelSidebar.ts:422-430`) | +| `empty` | Zero channels | "No channels yet" + hint (already the empty-state branch of `renderChannels()`, `components/ChannelSidebar.ts`) | | category collapsed | User toggles | Persisted per-server in localStorage (`ui.toggleCategory`); chevron reflects state | | active channel | `setActiveChannel` | Highlighted; unread cleared | | unread | `chat_message` in a non-active channel | Unread pill; badge on the channel | @@ -34,6 +34,18 @@ Each channel type gets a distinct icon and interaction: | `voice` | speaker | Join voice (see [voice-and-e2ee.md](voice-and-e2ee.md)); shows the participant roster inline | | `dm` | — | Not in the channel list; lives in DM mode | +### 1.1a Per-channel notification mutes + +The channel context menu offers "Mute Channel" / "Unmute Channel" +(the Mute Channel item in `attachChannelContextMenu()`, `components/channel-sidebar/context-menu.ts`, backed by `lib/channel-mutes.ts`). +Discord semantics, deliberately: a mute silences the channel's *noise* — no +desktop notification, no chime — while the unread badge still counts but +renders dimmed, and a message that mentions you still notifies and shows the +red mention badge. It is a client-side preference on purpose (stored in +`localStorage` under `mutedChannels`): the server has no per-user channel +settings table, and "which of my devices bothers me" is a property of the +device, not the account. + ### 1.2 Channel switching ```mermaid @@ -55,7 +67,9 @@ sequenceDiagram state for uncached history ([messaging.md §1](messaging.md)), never a global block. - If the active channel is **deleted** server-side (`channel_delete`), redirect to the first text channel by position and toast "This channel was deleted." - (redirect already exists, `dispatcher.ts:286-292`; add the toast). + (**✓ implemented 2026-08** — the `channel_delete` handler in + `wireDispatcher()`, `lib/dispatcher.ts`, redirects and toasts; a non-active + deletion stays silent). ### 1.3 Reorder & CRUD (admin) @@ -74,10 +88,10 @@ role grouping. | State | Trigger | Target reaction | |-------|---------|-----------------| | `ready` | `ready.members` | Grouped by role, sorted; presence dot per member | -| `empty` | No online members | "No members online" (already `MemberList.ts:167-170`) | +| `empty` | No online members | "No members online" (already the empty-state branch of `renderList()`, `components/MemberList.ts`) | | presence change | `presence` event | Live dot update; offline members styled distinctly | | role change | `member_update` | Re-group live | -| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already `dispatcher.ts:334-341`) | +| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already the `user_update` handler in `wireDispatcher()`, `lib/dispatcher.ts`) | | join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash | ### 2.1 Typing indicator @@ -85,7 +99,7 @@ role grouping. `typing` events populate `members.typingUsers` with a 5 s auto-clear timer. **Target:** show "X is typing…" / "X and Y are typing…" / "Several people are typing…" below the message list, excluding the current user (already -`TypingIndicator.ts:35`). The client emits `typing_start` while composing +`formatTypingText()` in `components/TypingIndicator.ts`). The client emits `typing_start` while composing (debounced), never per-keystroke. ### 2.2 Member actions (context menu) @@ -112,10 +126,10 @@ recipient, last-message preview, unread). |-------|---------|-----------------| | `ready` | `ready.dm_channels` | DM list sorted by recency | | `empty` | No DMs | "No direct messages yet" + "Start one from a member's profile" | -| open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `dm.store.ts:38`) | +| open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `addDmChannel()`, `stores/dm.store.ts`) | | close DM | `dm_channel_close` | Remove from list | | new DM message | `chat_message` in a DM | `updateDmLastMessage` (unread bump + reorder) if not focused; `updateDmLastMessagePreview` (no bump) if own/active | -| last-message empty | Never messaged | "No messages yet" fallback (already `SidebarDmHelpers.ts:127`) | +| last-message empty | Never messaged | "No messages yet" fallback (already the `lastMessage` fallback in `buildDmConversations()`, `pages/main-page/SidebarDmHelpers.ts`) | ### 3.1 Opening a DM @@ -133,6 +147,20 @@ sequenceDiagram Note over U,DM: server also broadcasts dm_channel_open to both parties ``` +### 3.1a Group DMs + +One picker covers 1:1 and group creation +(`pages/main-page/MemberPickerModal.ts`): selecting a single member opens a +1:1 DM, selecting two or more creates a group (cap +the `MAX_GROUP_DM_PARTICIPANTS = 10` constant in `lib/constants.ts`) — "new conversation" +is one intent, so the user is not asked to choose DM-vs-group up front. Group +DMs are `channels` rows with `type='dm'` and `is_group=1` server-side +(migration 028), so leaving a two-person group does not collapse it back into +a 1:1. "Rename Group" / "Leave Group" affordances live in the DM row context +menu (the contextmenu handler in `renderDmItem()`, `components/DmSidebar.ts`; leave doubles as "Close DM" for 1:1s); +ring/incoming calls work the same as 1:1 DMs (`call_ring` fans out to every +other participant). + ### 3.2 Blocking Blocking gates DM delivery server-side (a blocked user can't post into the DM, @@ -155,11 +183,12 @@ and `IsEitherBlocked` is bidirectional). **Target UX:** > `blocks.store`, so an unblock (shrunken `GET /blocks`) re-enables the composer > live. `blockedByMe` takes precedence when both directions apply. > -> **Remaining gap.** There is no in-client **block button** yet (the block/unblock -> REST surface exists server-side; blocks made from the web panel or a prior -> session are honoured via `GET /blocks`). Adding the block affordance to the DM -> profile sidebar would call `PUT/DELETE /blocks/{userId}` and update `blocks.store` -> directly for an instant local un-gate. +> **✓ Implemented (2026-07/08).** The in-client **Block/Unblock** affordance now +> lives in the member context menu (`AdminActions.ts` renders the item; +> `MemberList.ts` passes it through; the `onToggleBlock` handler in `createSidebarMemberSection()` (`pages/main-page/SidebarMemberSection.ts`) calls +> `api.blockUser`/`api.unblockUser`, updates `blocks.store` via +> `setUserBlockedByMe` for an instant local un-gate, and confirms with a +> success toast — or an error toast on failure). --- diff --git a/docs/architecture/ux/connection-and-auth.md b/docs/architecture/ux/connection-and-auth.md index 0f4dd4d9..e89cb255 100644 --- a/docs/architecture/ux/connection-and-auth.md +++ b/docs/architecture/ux/connection-and-auth.md @@ -1,6 +1,6 @@ # Connection & Authentication — target UX -**Verified against:** commit `da4acc5`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Part of the [Client UX Specification](README.md). Shared vocabulary, feedback primitives, and the error matrix live in the [README](README.md) and are not repeated here. @@ -31,7 +31,7 @@ stateDiagram-v2 **Target rule:** the transition `Connect → Main` is gated by the **connected overlay**, which resolves only on the `ready` event — never navigate to Main on a -bare socket-open. (Already the case: `main.ts:270-286`.) This guarantees Main +bare socket-open. (Already the case: `wirePostAuth()` inside `renderPage()` in `main.ts`.) This guarantees Main never renders against empty stores. --- @@ -51,25 +51,26 @@ status area. Settings are reachable unauthenticated (for appearance/advanced). | health: reachable | `GET /api/v1/health` ok within 3 s | Green dot + server name/MOTD preview | | health: unreachable | timeout/opaque error | Amber "unreachable" dot; **do not** block selecting it (user may still try) | -Health polls every 15 s (`profiles.ts`); auto-connect, if enabled for the active -profile, drives the login form's `auto-connecting` state. +Health polls every 15 s (interval wired in `main.ts`, profile data via +`profiles.ts`); auto-connect, if enabled for the active profile, drives the +login form's `auto-connecting` state. ### 2.2 Login form — state machine The form is an explicit FSM: `idle | loading | totp | connecting | error | -auto-connecting` (`LoginForm.ts:12`). This is the model other views should +auto-connecting` (the `FormState` type in `pages/connect-page/LoginForm.ts`). This is the model other views should follow. | State | Presentation | Exit | |-------|--------------|------| | `idle` | Enabled fields; Login/Register toggle | submit → validate | -| `loading` | Submit shows spinner, fields disabled (`LoginForm.ts:232-235,443-446`) | `auth.login` resolves | +| `loading` | Submit shows spinner, fields disabled (`updateSubmitButton()` + `updateFormInputsDisabled()` in `LoginForm.ts`) | `auth.login` resolves | | `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` | | `connecting` | "Connecting…" while WS handshakes | ws `connected` | | `auto-connecting` | Dedicated spinner card for saved-profile auto-login | any key/click cancels to `idle` | -| `error` | Shake-animated banner, server message capped 200 chars (`LoginForm.ts:590-606`) | user edits → `idle` | +| `error` | Shake-animated banner, server message capped 200 chars (the `handleFormSubmit()` catch + `updateErrorBanner()` in `LoginForm.ts`) | user edits → `idle` | -**Client-side validation before any request** (`LoginForm.ts:536-560`): host, +**Client-side validation before any request** (`validateForm()` in `LoginForm.ts`): host, username, password required; password ≥ 8; register mode also requires the invite code. Validation failures never hit the network. @@ -107,7 +108,7 @@ sequenceDiagram | Server result | Target reaction | |---------------|-----------------| | `200 {token, user}` | Proceed to WS connect | -| `200 {partial_token, requires_2fa}` | TOTP overlay; on cancel, clear the partial token (already cleared in `finally`, `main.ts:377-380`) | +| `200 {partial_token, requires_2fa}` | TOTP overlay; on cancel, clear the partial token (already cleared: the `onTotpSubmit` handler's `finally` in `main.ts` resets `pendingTotpPartialToken`) | | `403` banned/suspended | Error banner with the server message; remain on the form | | `403` require-2FA-but-none-set | Error banner directing the user to set up 2FA on the web panel | | `400` invalid input | Inline field error | @@ -153,7 +154,7 @@ It exists specifically so Main never renders mid-populate. Everything else ## 4. Reconnect UX The WS client auto-reconnects with exponential backoff (base 1 s, cap 30 s, no -jitter/cap; `ws.ts:123-126`), preserving `last_seq` for replay. The user-facing +jitter; the `DEFAULT_MAX_RECONNECT_DELAY` constant in `lib/ws.ts`), preserving `last_seq` for replay. The user-facing contract: ```mermaid @@ -169,10 +170,10 @@ stateDiagram-v2 | Phase | Target reaction | |-------|-----------------| -| `reconnecting` | `ServerBanner.showReconnecting()` (already `MainPage.ts:199-211`); **live-only controls disable** via connection status (§3 of README); drafted input preserved | -| replay resync | Silent when the ring buffer covers `last_seq`; deduped so no double-render (`ws.ts:212-231`); unread suppressed during replay (`dispatcher.ts:195`) | +| `reconnecting` | `ServerBanner.showReconnecting()` (already `applyConnectionStatus()`, `components/ServerBanner.ts`, invoked from MainPage's connectionStatus subscription); **live-only controls disable** via connection status (§3 of README); drafted input preserved | +| replay resync | Silent when the ring buffer covers `last_seq`; deduped so no double-render (the replay-dedup block inside `handleMessage()`, `lib/ws.ts`); unread suppressed during replay (the `chat_message` handler's `!ws.isReplaying()` guard in `wireDispatcher()`, `lib/dispatcher.ts`) | | full resync | If `last_seq` predates buffer coverage, server replays from the events table or forces a full `ready`; the UI simply re-populates — no user action | -| `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`ServerBanner.ts:28-43`) | +| `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`showRestart()`, `components/ServerBanner.ts`) | | fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page | **Target rule:** reconnection is invisible on the happy path and honest on the @@ -186,14 +187,18 @@ a click and failing. ## 5. Cert trust (TOFU) prompts -The Rust proxies pin the server cert on first use and emit `cert-tofu` events. -The HTTP proxy usually establishes the pin first (login precedes WS). +The Rust proxies validate the server cert against the per-host pin store and +emit `cert-tofu` events. **Deciding never writes a pin** (`tofu.rs`): an +unknown host's first connection is *rejected* until the user confirms the +fingerprint, so no credential is ever sent to an unconfirmed host. The HTTP +proxy usually sees the host first (the connect page's health check precedes +login and WS). | Event | Target reaction | Current | |-------|-----------------|---------| -| `trusted_first_use` | 8 s informational banner "Trusting this server's certificate" | Implemented ad hoc in `main.ts:105-129` | +| `first_use` | **Blocking trust modal** (`createCertFirstUseModal`) showing host + fingerprint; **Accept** stores the pin (`accept_cert_fingerprint`), re-runs the connect-page health check and resumes a pending connect; **Cancel** leaves the host untrusted (health stays "unreachable") | Implemented in the `ws.onCertFirstUse(...)` handler in `main.ts`; shares a `certModalActive` guard with the mismatch modal so the two never stack | | `trusted` | No UI (silent, expected) | — | -| `mismatch` | **Blocking** `CertMismatchModal`: explain the fingerprint changed; **Accept** re-pins (`accept_cert_fingerprint`) + reconnects; **Reject** disconnects, `clearAuth()`, → connect page | Implemented `main.ts:133-164`; reconnect blocked until resolved (`certMismatchBlock`) | +| `mismatch` | **Blocking** `CertMismatchModal`: explain the fingerprint changed; **Accept** re-pins (`accept_cert_fingerprint`) + reconnects; **Reject** disconnects, `clearAuth()`, → connect page | Implemented in the `ws.onCertMismatch(...)` handler in `main.ts`; reconnect blocked until resolved (`certMismatchBlock`) | ```mermaid sequenceDiagram diff --git a/docs/architecture/ux/messaging.md b/docs/architecture/ux/messaging.md index 149ae571..d56c4ebd 100644 --- a/docs/architecture/ux/messaging.md +++ b/docs/architecture/ux/messaging.md @@ -1,6 +1,6 @@ # Messaging — target UX -**Verified against:** commit `da4acc5`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Part of the [Client UX Specification](README.md). Shared vocabulary and the error matrix live in the [README](README.md). @@ -18,8 +18,8 @@ The list renders from `messages.store` (`messagesByChannel`, capped 500/channel) |-------|---------|-----------------| | `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area | | `ready` | Messages present | Virtualized list | -| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `MessageList.ts:109-125`) | -| `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already `MessageList.ts:459-468`) | +| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `renderEmptyState()`, `components/MessageList.ts`) | +| `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already the scroll-top `hasMore` branch of `handleScroll()`, `components/MessageList.ts`) | | `error` | History fetch failed | **Inline section error + Retry** in the message area | > **✓ Implemented (2026-07).** `messages.store` tracks a per-channel @@ -61,7 +61,7 @@ stateDiagram-v2 | `no-permission` | Disabled bar | "You don't have permission to send messages here." | | `offline` | Disabled — "Reconnecting…" while retrying, "Not connected" when disconnected | connection status (README §3) | | `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." | -| `uploading` | Send disabled until uploads settle (already `MessageInput.ts:138-141`) | per-attachment spinner | +| `uploading` | Send disabled until uploads settle (already the `pendingUploadCount` guard in `handleSend()`, `components/MessageInput.ts`) | per-attachment spinner | > **✓ Implemented (2026-07).** The server sends an authoritative per-channel > `can_send` in the ready payload (`ws/serve.go` `channelCanSend`, mirroring @@ -70,8 +70,9 @@ stateDiagram-v2 > `Channel.canSend`; `MessageInput.setDisabled(reason)` disables the composer > with a visible reason, and `ChannelController` derives that reason from > `can_send` + channel type + connection status. Older servers that omit -> `can_send` default permissive. Remaining: slow-mode countdown (see §8) and DM -> block-state gating (handled today via the failed-row path in §3). +> `can_send` default permissive. The slow-mode countdown has since shipped too +> (see §8); DM block-state gating is handled via `dmComposerBlockReason` in the +> same composer-reason derivation. --- @@ -136,7 +137,7 @@ existing pending/sent row for that id and replace-in-place rather than append. | Action | Target UX | |--------|-----------| | Edit (own message) | Inline edit in the composer (`startEdit`, `MessageInput.ts`); optimistic content swap; `chat_edited` reconciles + stamps "edited"; failure rolls back with a toast | -| Delete (own / moderator) | **Two-click confirm** on the row (`PendingDeleteManager`, `MessageController.ts:32-54`); optimistic tombstone; `chat_deleted` confirms; failure restores the row + toast | +| Delete (own / moderator) | **Two-click confirm** on the row (`createPendingDeleteManager()`, `pages/main-page/MessageController.ts`); optimistic tombstone; `chat_deleted` confirms; failure restores the row + toast | | Delete (no permission) | The delete affordance is not offered on others' messages unless the user has MANAGE_MESSAGES | Deleted messages are soft-deleted (kept as a tombstone in the array, `deleted:true`) @@ -151,9 +152,16 @@ so surrounding context and reply references stay intact. | Add/remove reaction | Optimistic pill toggle + count adjustment, reflecting `me`; `reaction_update` echo reconciles; failure rolls the pill back | | Emoji picker | `EmojiPicker` with recent-emoji memory (`owncord:recent-emoji`) | -> Current: reactions render only from the server `reaction_update` echo -> (`messages.store.ts:282`); there is no local optimistic toggle. Target adds the -> optimistic toggle for immediacy, consistent with §3. +> **✓ Implemented (2026-08).** The pill toggles on the click: +> `ReactionController.sendReaction` applies the toggle locally +> (`addOptimisticReaction`, `stores/messages.store.ts`) under the send's WS +> envelope id — the same correlation scheme as §3's optimistic rows. +> `updateReaction` consumes the matching self-echo instead of re-applying it +> (the delta arithmetic would double-count), other users' echoes apply +> normally, and an error reply or transport failure rolls back exactly that +> toggle (`rollbackReaction`, wired in the dispatcher's error and +> send-failure handlers). The pill reverting is the failure feedback — no +> toast on top. **Who reacted (✓ implemented 2026-08):** hovering (or focusing) a reaction pill for 300 ms fetches the reactor list and shows a tooltip reading @@ -175,8 +183,8 @@ upload state (already thorough — `MessageInput.ts`). | State | Presentation | |-------|--------------| | selected | Thumbnail/chip per file | -| validating | Reject oversize/disallowed type inline via `showUploadError` (`MessageInput.ts:114-129`) | -| uploading | Per-item spinner; **send disabled** until all settle (`MessageInput.ts:243-247`) | +| validating | Reject oversize/disallowed type inline via `showUploadError` (the `MAX_FILE_SIZE`/`ALLOWED_TYPES` validation in `handlePasteFile()`, `components/MessageInput.ts`) | +| uploading | Per-item spinner; **send disabled** until all settle (the per-item uploading preview in `handlePasteFile()` + the `handleSend()` upload guard, `components/MessageInput.ts`) | | uploaded | Chip ready; ids attached to the `chat_send` payload | | failed | Inline error on the chip with remove/retry | @@ -212,9 +220,9 @@ string and park it in the LRU + IndexedDB caches. | Feature | Target UX | |---------|-----------| | Reply | Reply target chip above the composer (`setReplyTo`/`clearReply`); `reply_to` sent; rendered as a quoted preview | -| Pin/unpin | Optimistic (`setMessagePinned`, already optimistic `messages.store.ts:226-240`); pinned panel lists them, empty state "No pinned messages" (already `PinnedMessages.ts:81-89`) | -| Search | Overlay with a status line cycling *type-N-chars → searching → results → no results → failed* (already thorough `SearchOverlay.ts:123-145`); abort in-flight on new query | -| Read/unread | Unread badge per channel; cleared on focus (`setActiveChannel`); incremented only for non-active, non-own, non-replay messages (`dispatcher.ts:195`); focus emits `channel_focus` for server read-state | +| Pin/unpin | Optimistic (`setMessagePinned()`, already optimistic in `stores/messages.store.ts`); pinned panel lists them, empty state "This channel doesn't have any pinned messages… yet!" (already `renderEmptyState()`, `components/PinnedMessages.ts`) | +| Search | Overlay with a status line cycling *type-N-chars → searching → results → no results → failed* (already thorough: `doSearch()`/`setStatus()` in `components/SearchOverlay.ts`); abort in-flight on new query | +| Read/unread | Unread badge per channel; cleared on focus (`setActiveChannel`); incremented only for non-active, non-own, non-replay messages (the `chat_message` handler in `wireDispatcher()`, `lib/dispatcher.ts`); focus emits `channel_focus` for server read-state | **Read-state target rule:** unread counts must be suppressed during reconnect replay (already handled via `isReplaying()`), so catching up 500 buffered @@ -338,11 +346,16 @@ channel's `slow_mode` seconds) and re-enable at zero; on a WS `SLOW_MODE` rejection, snap the composer to the countdown state without dropping the drafted text. -> **Partially implemented (2026-07).** `SLOW_MODE` errors are now surfaced: they -> mark the optimistic row failed with a "Slow mode — wait before sending again" -> reason and a **Retry** (via the request-id error correlation in §3). The live -> **countdown** in the composer is still outstanding — it needs the channel's -> `slow_mode` seconds, which the ready payload does not yet carry. +> **✓ Implemented (2026-07/08).** `SLOW_MODE` errors mark the optimistic row +> failed with a "Slow mode — wait before sending again" reason and a **Retry** +> (via the request-id error correlation in §3). The live countdown exists too: +> the ready payload carries per-channel `slow_mode` seconds +> (`Channel.slowMode` in `channels.store`), and `ChannelController`'s +> `startSlowMode`/`computeComposerReason` disable the composer with a ticking +> "Slow mode — Ns" reason after each accepted send (`chat_send_ok`) and snap +> to the full window on a `SLOW_MODE` rejection — without dropping the drafted +> text (the draft stays in the textarea). Moderators (`canManageMessages`) +> bypass the client gate exactly as they bypass the server's limiter. --- @@ -362,8 +375,8 @@ the same signal in future. ## Source of truth -`src/components/MessageList.ts` (+ `message-list/`), `src/components/MessageInput.ts` -(+ `message-input/`), `src/pages/main-page/ChannelController.ts`, +`src/components/MessageList.ts` (+ `message-list/`), `src/components/MessageInput.ts`, +`src/pages/main-page/ChannelController.ts`, `src/pages/main-page/MessageController.ts`, `src/pages/main-page/ReactionController.ts`, `src/stores/messages.store.ts`, `src/lib/dispatcher.ts`, `src/lib/ws.ts`, `src/components/SearchOverlay.ts`, `src/components/PinnedMessages.ts`, diff --git a/docs/architecture/ux/settings-and-admin.md b/docs/architecture/ux/settings-and-admin.md index 4d18cc1a..c3f630ea 100644 --- a/docs/architecture/ux/settings-and-admin.md +++ b/docs/architecture/ux/settings-and-admin.md @@ -1,6 +1,6 @@ # Settings & Admin — target UX -**Verified against:** commit `da4acc5`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Part of the [Client UX Specification](README.md). Covers: the settings overlay and its tabs, account operations (profile, password, @@ -71,14 +71,14 @@ committed, the operation is a **success** even if the session-revocation step fails — the UI must never present a committed change as an error (that would walk the user into the confirm-lockout). The partial-success `200 {warning}` maps to a success message with a soft note, never a red error. (Server contract: -`profile_handler.go:237-248`; client already toasts success, `MainPage.ts:280-283`.) +`handleUpdateProfile()` in `Server/api/profile_handler.go`; client already toasts success, the `onUpdateProfile` handler in `pages/MainPage.ts`.) ### 2.3 Two-factor (TOTP) | Flow | Steps | |------|-------| | Enable | Password prompt → `POST /totp/enable` → render QR URI + backup codes → 6-digit confirm → `POST /totp/confirm` → "Enabled" badge, `auth` user `totp_enabled:true` | -| Disable | Password confirm → `DELETE /totp`; a `403`/"required" is rewritten to "2FA is required by this server and cannot be disabled" (already `AccountTab.ts:442-451`) | +| Disable | Password confirm → `DELETE /totp`; a `403`/"required" is rewritten to "2FA is required by this server and cannot be disabled" (already the 403 rewrite in `buildTotpDisableView()`, `components/settings/AccountTab.ts`) | **Target rule:** backup codes are shown exactly once, with an explicit "Save these now — you won't see them again" and a copy affordance. @@ -111,12 +111,18 @@ and (b) confirm destructive actions. | Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" | **Target rules:** -- Destructive admin actions should show an **in-flight** state (today the - two-click label reverts immediately and only a toast reports the result — - `AdminActions.ts:54-78`; add a pending state so a slow ban doesn't look ignored). -- **Ban should collect a reason.** `adminBanMember` accepts a `reason` but the - menu passes none (`SidebarMemberSection.ts:159-166`). Target: a small reason - prompt on ban, since the server stores and displays it. +- **✓ Destructive admin actions show an in-flight state (2026-08).** + `withConfirmation` (`AdminActions.ts`) keeps the item in a pending + label/class while the promise settles and ignores further clicks, so a slow + ban no longer looks ignored; unblock, ban submit, purge, and the role-change + submenu carry their own equivalent guards (a role change in flight also + inerts the other role options — `currentRole` only updates when the + `member_update` echoes). +- **✓ Ban collects a reason (2026-07/08).** The ban flow renders an inline + reason input plus a duration choice (`appendBanFlow()` in `components/AdminActions.ts`), + and the menu passes both through + (the `onBan` handler in `createSidebarMemberSection()`, `pages/main-page/SidebarMemberSection.ts` → `api.adminBanMember(userId, reason, + durationHours)`), so temporary bans and stored reasons work from the client. ### 3.1 What is *not* in the client (by design) @@ -124,7 +130,11 @@ The full admin panel — user list, audit log, server settings, channel permissions, plugin management, backups, updates, first-run setup — is the **server-rendered web panel** under `/admin`, gated by IP restriction + admin auth. The Tauri client has **no** REST methods for these (confirmed: no plugin/ -audit/settings/permissions/setup calls in `api.ts`). +audit/settings/permissions/setup calls in `api.ts`). The one bridge the client +does have is a deep-link: `lib/admin-panel.ts` opens +`https://{host}/admin#{section}` in the OS browser (wired from +the Audit Log button handler in `createSidebarArea()`, `pages/main-page/SidebarArea.ts`, gated by +`lib/permissions.ts::canViewAuditLog`). > **Decision point.** If the target is for admins to manage the server from the > desktop app (audit log, settings, plugins) rather than the web panel, that is a @@ -170,7 +180,7 @@ sequenceDiagram | State | Presentation | |-------|--------------| | checking | Silent (no UI until a result) | -| available | Non-modal banner with version + Update Now / Later (already `UpdateNotifier.ts:30-62`) | +| available | Non-modal banner with version + Update Now / Later (already `createUpdateNotifier()`/`showBanner()`, `components/UpdateNotifier.ts`) | | downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) | | applied | App relaunches automatically | | failed | "Update failed. Please try again later." + Dismiss | @@ -185,6 +195,17 @@ sequenceDiagram --- +## 6. System tray + +The tray icon (`src-tauri/src/tray.rs`) is a parallel presence/window surface: +**Show/Hide** toggles the main window, a **Status** submenu +(Online / Idle / Do Not Disturb / Offline) emits a `status-change` event that +the TS side applies through the same presence path as the user-bar picker +(`lib/userStatus.ts` / `components/StatusPicker.ts`), and **Quit** exits the +app. + +--- + ## Source of truth `src/components/SettingsOverlay.ts` (+ `settings/*`), `src/components/AdminActions.ts`, diff --git a/docs/architecture/ux/voice-and-e2ee.md b/docs/architecture/ux/voice-and-e2ee.md index 00a89df3..4e9bb765 100644 --- a/docs/architecture/ux/voice-and-e2ee.md +++ b/docs/architecture/ux/voice-and-e2ee.md @@ -1,6 +1,6 @@ # Voice, Video & E2EE — target UX -**Verified against:** commit `da4acc5`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Part of the [Client UX Specification](README.md). The signaling/crypto mechanics are mapped structurally in [../voice-e2ee.md](../voice-e2ee.md); this document specifies the **user-facing** states and reactions. @@ -61,9 +61,9 @@ stateDiagram-v2 | Status | Presentation | Notes | |--------|--------------|-------| | `joining` | Voice widget shows "Connecting…"; channel roster shows self pending | `handleVoiceToken` → `connectAndSetup` | -| `securing` | "Securing connection…" indicator (lock, in-progress) | Non-key-holders block here until a room key arrives (10 s + 5 s retry, `livekitSession.ts:860-902`) | +| `securing` | "Securing connection…" indicator (lock, in-progress) | Non-key-holders block here until a room key arrives (10 s + 5 s retry, the "securing" key-exchange block in `connectAndSetup` (`lib/livekitSession.ts`) / `E2EEManager.setupKeyExchange` (`lib/livekitE2EE.ts`)) | | `connected` | "Voice connected · secured 🔒" + elapsed timer (from `joinedAt`) | E2EE active; per-user tiles live | -| `reconnecting` | "Reconnecting voice…"; controls frozen, not torn down | Keypair regenerated for forward secrecy (`livekitSession.ts:451-468`) | +| `reconnecting` | "Reconnecting voice…"; controls frozen, not torn down | Keypair regenerated for forward secrecy (`attemptAutoReconnect()` → `reannounceForReconnect()`, `lib/livekitSession.ts`) | | `failed` | Toast "Voice connection lost" / "Couldn't secure the call"; auto-leave | `onErrorCallback` fires | **Target rules:** @@ -95,8 +95,8 @@ All four are optimistic with rollback; each also emits a WS control message. |---------|-------------|-----------|----------| | **Mute** | `localMuted` (`setLocalMuted`) — fully unpublishes the mic track | `voice_mute{muted}` | n/a (local-authoritative) | | **Deafen** | `localDeafened` + forces mute — unsubscribes remote *voice* audio only; screen-share/stream audio keeps playing (it has its own per-tile mute/volume) | `voice_deafen` + `voice_mute` | implies mute | -| **Camera** | `localCamera` set optimistically, rolled back on device failure (`screenShare.ts:177,204`) | `voice_camera{enabled}` | revert on failure + toast | -| **Screenshare** | `localScreenshare` optimistic, rollback on failure (`screenShare.ts:265,311`); rate-limited | `voice_screenshare{enabled}` | revert + toast | +| **Camera** | `localCamera` set optimistically, rolled back on device failure (`enableCamera()` in `lib/screenShare.ts`) | `voice_camera{enabled}` | revert on failure + toast | +| **Screenshare** | `localScreenshare` optimistic, rollback on failure (`enableScreenshare()` in `lib/screenShare.ts`); rate-limited | `voice_screenshare{enabled}` | revert + toast | | Control state | Presentation | |---------------|--------------| @@ -110,7 +110,7 @@ All four are optimistic with rollback; each also emits a WS control message. **Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set `listenOnly` and surface the specific reason ("Microphone permission denied" / "No microphone found") as a toast with a retry — already wired to -`onErrorCallback` (`livekitSession.ts:734-743`); the spec makes the **Retry mic** +`onErrorCallback` (the mic-unavailable branches of `restoreLocalVoiceState()`, `lib/livekitSession.ts`); the spec makes the **Retry mic** control a permanent part of the listen-only badge. --- @@ -118,7 +118,7 @@ control a permanent part of the listen-only badge. ## 4. Push-to-talk PTT is a Rust key-poller (`ptt.rs`, 20 ms) emitting `ptt-state{pressed}` → -`setMuted(!pressed)` only while in a channel (`ptt.ts:98-105`). **Target UX:** +`setMuted(!pressed)` only while in a channel (the `ptt-state` listener inside `initPtt()`, `lib/ptt.ts`). **Target UX:** | State | Presentation | |-------|--------------| @@ -137,7 +137,7 @@ reflects their `speaking/muted/deafened/camera/screenshare`. **Target:** | Signal | Tile reaction | |--------|---------------| | `voice_state` | Add/update the participant with their flags | -| `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already `dispatcher.ts:364-367`) | +| `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already the `voice_leave` handler in `wireDispatcher()`, `lib/dispatcher.ts`) | | `voice_speakers` | Speaking ring on the listed users | | key-holder change | Invisible to users (re-election is automatic on leave); no UI churn | @@ -154,11 +154,67 @@ forward-secrecy keypair rotation on reconnect are mechanics the user never sees. --- +## 7. E2EE identity verification surface + +Peer identity state lives in `voice.store` (per-participant +`status: verified | unverified | mismatch` + `safetyNumber`), written by +`lib/livekitE2EE.ts` as announces are verified against the pinned identity +keys (`lib/identity.ts`). + +| State | Roster badge (`verifyPresentation()`, `components/ChannelSidebar.ts`) | Interaction | +|-------|------------------------------------------|-------------| +| `verified` | Green shield; title "Identity verified · Safety number: {n}" | none needed | +| `unverified` | Neutral shield; no pinned key yet | none — pins on first verified announce | +| `mismatch` | Red shield-alert; title "Identity key changed — click to review and re-pin" | Click → blocking identity-mismatch modal | + +The mismatch modal (`createIdentityMismatchModal()`, `components/CertMismatchModal.ts`; +opened from `openIdentityMismatchModal()` in `components/ChannelSidebar.ts`) shows the **new key's fingerprint** so +the user can verify it out-of-band before trusting. "Trust New Key" re-pins +via `rePinPeerIdentity` — deliberately pinning the exact key whose fingerprint +was displayed, not a fresh store read, so a malicious server cannot swap the +key during the human verification window (TOCTOU). Reject leaves the peer +blocked for E2EE media. A stripped or malformed published key disables the +trust action entirely (a blind accept is refused). + +## 8. Media processing & devices + +- **Noise suppression:** RNNoise WASM worklet (`lib/noise-suppression.ts`, + assets `public/rnnoise.wasm` + `public/rnnoise-worklet.js`), toggled in + Settings → Voice & Audio; falls back to a ScriptProcessorNode pipeline when + AudioWorklet is unavailable (`createScriptProcessorPipeline()` in `lib/noise-suppression.ts`). +- **Input volume & VAD:** `lib/audioPipeline.ts` applies input gain and + voice-activity gating ahead of publish. +- **Device hot-swap:** `lib/deviceManager.ts` follows OS device + plug/unplug and re-routes the active input/output without rejoining. +- **Stream preview:** `lib/streamPreview.ts` renders the pre-share preview in + the screen-share picker. + +## 9. DM calls (ring) + +DM voice is the same voice machinery on the DM's voice channel, plus a ring +layer (no server-side call state — presence in the DM voice channel *is* the +call): + +| Event | Reaction | +|-------|----------| +| Outgoing: user clicks Call | `call_ring` sent (rate-limited 1/3 s server-side); caller joins the DM voice channel | +| Incoming: `call_incoming` | `components/IncomingCallBanner.ts` banner + ring chime (`lib/notifications.ts`), driven by the `lib/call-ring.ts` state machine (30 s auto-timeout) | +| Accept | Join the DM voice channel; banner clears | +| Decline | `call_decline` sent → other participants' ringing stops via `call_declined` | +| Timeout / caller leaves | Banner clears silently | + +`call_incoming` / `call_declined` are page-scoped listeners in `MainPage.ts`, +not dispatcher handlers (see [README §4](README.md)). + +--- + ## Source of truth -`src/lib/livekitSession.ts`, `src/stores/voice.store.ts`, `src/lib/screenShare.ts`, +`src/lib/livekitSession.ts`, `src/lib/livekitE2EE.ts`, +`src/stores/voice.store.ts`, `src/lib/screenShare.ts`, `src/lib/ptt.ts`, `src/lib/roomEventHandlers.ts`, `src/components/VoiceWidget.ts`, -`src/components/ChannelSidebar.ts` (voice-row join freeze on WS reconnect), -`VoiceChannel.ts`, `VideoGrid.ts`, `src-tauri/src/livekit_proxy.rs`, -`src-tauri/src/ptt.rs`, `src/lib/e2eeCrypto.ts`; and the structural map in +`src/components/ChannelSidebar.ts` (voice rows, join freeze on WS reconnect, +and the E2EE verification badge), `src/components/VideoGrid.ts`, +`src-tauri/src/livekit_proxy.rs`, `src-tauri/src/ptt.rs`, +`src/lib/e2eeCrypto.ts`, `src/lib/identity.ts`; and the structural map in [../voice-e2ee.md](../voice-e2ee.md). diff --git a/docs/architecture/voice-e2ee.md b/docs/architecture/voice-e2ee.md index aecf9de1..7b96b272 100644 --- a/docs/architecture/voice-e2ee.md +++ b/docs/architecture/voice-e2ee.md @@ -1,6 +1,6 @@ # Voice and End-to-End Encryption -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 Voice/video runs on LiveKit. The Go server issues short-lived scoped tokens and relays E2EE key-exchange messages; media flows client↔LiveKit directly. On the @@ -58,14 +58,20 @@ Supporting pieces: admin-IP-restricted), `Server/api/livekit_proxy.go` (HTTP reverse proxy). - **Client:** `src/lib/livekitSession.ts` (state machine: idle/connecting/ connected/reconnecting with a monotonic `joinGeneration` to discard - superseded joins), `src/lib/e2eeCrypto.ts` (ECDH, key wrap/unwrap), + superseded joins), `src/lib/livekitE2EE.ts` (key-holder election, room-key + wrap/unwrap, peer verification state), `src/lib/e2eeCrypto.ts` (ECDH + primitives, safety-number fingerprints, long-term identity keys), + `src/lib/identity.ts` (OS-keyring identity key + peer identity pins), `src/lib/audioPipeline.ts` + `src/lib/noise-suppression.ts` (RNNoise WASM), `src/lib/screenShare.ts`, `src-tauri/src/livekit_proxy.rs` (tunnel), `src-tauri/src/ptt.rs` (push-to-talk key polling). -This message flow (`voice_e2ee_announce` / `voice_e2ee_offer` / -`voice_speakers`) is currently **absent from `docs/protocol.md`** — recorded as -spec drift in [audit-2026-07-19.md §2](../audit-2026-07-19.md). +The wire flow (`voice_e2ee_announce` / `voice_e2ee_offer` / `voice_speakers`) +is specified in [protocol.md](../protocol.md) (Voice End-to-End Encryption +section). Long-term identity: each user publishes an ECDSA identity public key +(`users.identity_public_key`, migration 017); peers pin it on first contact +and surface a blocking mismatch modal if it later changes (see +[ux/voice-and-e2ee.md](ux/voice-and-e2ee.md)). **Source of truth:** `Server/ws/voice_e2ee.go`, `Server/ws/livekit.go`, `Client/tauri-client/src/lib/livekitSession.ts`, diff --git a/docs/architecture/websocket.md b/docs/architecture/websocket.md index 6f28354f..e5dc5e16 100644 --- a/docs/architecture/websocket.md +++ b/docs/architecture/websocket.md @@ -1,18 +1,21 @@ # WebSocket / Real-time Engine -**Verified against:** commit `ddc49f0`, 2026-07-19 +**Verified against:** commit `5630aa1`, 2026-08-04 -The `Server/ws` package (~6.9k LOC, the largest in the server) implements the -real-time engine: a single `Hub` owning all client connections, a topic-based -pub/sub, a monotonic sequence counter, a 3-tier reconnect replay pipeline, and a -single typed (V2) command dispatch. Message-type constants live in -`Server/ws/message_types.go` (client↔server) and mirror -`Client/tauri-client/src/lib/protocolTypes.ts`. +The `Server/ws` package (~9.5k LOC production code, the largest in the server) +implements the real-time engine: a single `Hub` owning all client connections, +a topic-based pub/sub, a monotonic sequence counter, a 3-tier reconnect replay +pipeline, and a single typed (V2) command dispatch. -> Both files claim to be "Generated from docs/protocol-schema.json — single -> source of truth", but **no such file exists in the repository** — the -> constants are maintained by hand on both sides. See -> [audit-2026-07-19.md §3](../audit-2026-07-19.md). +Message-type constants are **generated**: `docs/protocol-schema.json` is the +single source of truth, and `Server/scripts/genprotocol` emits both +`Server/ws/message_types.go` and +`Client/tauri-client/src/lib/protocolTypes.ts` from it +(`make protocol-generate`; CI fails on drift via `make protocol-verify`). +The one exception is the plugin command family (`chat_command`, +`command_reply`, `plugin_broadcast`), declared by hand in +`Server/ws/handlers_command.go` outside the schema — see +[protocol.md](../protocol.md). ## D4a — Connect, authenticate, replay diff --git a/docs/audit-2026-04-07.md b/docs/audit-2026-04-07.md index 50ec17c5..b5a8ea01 100644 --- a/docs/audit-2026-04-07.md +++ b/docs/audit-2026-04-07.md @@ -39,12 +39,12 @@ rate limit must exist. | 3 | CRITICAL | Plugin per-command ACL missing (auto-registration) | **CLOSED 2026-07-20 (this PR)** — the manifest is now the per-command ACL. `plugin.json` gains a `commands` block; `RegisterCommand` refuses any name the manifest did not declare (`Server/plugin/host_commands.go:31-45`, `ErrCommandNotDeclared`), which is the single choke point both `list_commands` auto-registration (`sandbox_wazero.go:146-153`) and direct registration route through. A guest can therefore no longer widen its own command surface, and an admin can see the full command list before enabling. Declared names are validated to the dispatcher's canonical form, deduplicated, and capped at 64 (`manifest.go:207-233`). Cross-plugin hijack was already refused and stays refused. Pinned by `TestRegisterCommandRequiresManifestDeclaration` + `TestManifestCommandsValidation` | | 4 | CRITICAL | No rate limit on event delivery to plugins | **CLOSED 2026-07-20 (no guest code on the event path)** — there is no guest delivery to rate-limit. Note what *is* wired, so this is not mistaken for an absent call site: `EventSink.Dispatch` has exactly one caller outside the `plugin` package's tests — `Server/ws/hub.go:1034`, invoked on **every** broadcast message whenever an operator enables plugins (`Server/api/router.go:134-139` sets `h.pluginSink` when the registry is non-nil), on the hub's broadcast goroutine while `seqMu` is held. What makes the finding unreachable is one level down: `Dispatch`'s loop body invokes no guest code in either build (it touches no `inst.module`), and no production code calls `EventSink.Subscribe` — only tests — so `subs` is empty and the loop never iterates. A plugin cannot slow the hub by handling events slowly because no plugin ever handles one. Recorded as a gate rather than left silent: the SECURITY GATE comment on `Server/plugin/host_events.go` requires the per-plugin rate limit, the `invokeCommand` CPU deadline, and off-hub-goroutine delivery to land *in the same change* that wires guest delivery — and flags that the hot call site already exists, so wiring is a one-line `Subscribe` away, not a new integration. `TestEventDeliveryHasNoGuestPath` fails if delivery appears without that review | | 5 | CRITICAL | Plugin HTTP capability allows data exfiltration to allowlisted hosts | **OPEN — accepted residual risk (2026-07-20)**. Not fixable by hardening: an allowlisted host is by definition a permitted destination, so a plugin holding `http` can POST anything it can read to it. Closing it properly needs egress content policy (per-plugin request/response body inspection, byte budgets, per-plugin allowlists instead of one server-wide list) — a plugin-runtime redesign, not a patch. Standing mitigations, all verified in code: (a) `plugins.enabled` defaults false; (b) `plugins.http_allowlist` defaults empty and an empty allowlist denies every host, so the capability is inert until an operator names a destination; (c) the manifest must declare `http`, which is visible to the admin before enabling; (d) no host import is wired, so guest code cannot call `HTTPDo` at all today; (e) SSRF hardening (allowlist dot-boundary matching, guarded dial that vets every resolved IP before connecting, redirect re-checks, 5 MiB response cap) confines reach to public allowlisted hosts. Residual risk accepted for alpha/beta: an operator who both enables plugins and allowlists a host trusts the plugins they install with data those plugins can read | -| 6 | HIGH | `Server/store/` untested | SUPERSEDED — `store/` package is being removed in P4 (single data layer); tests move to in-memory SQLite | -| 7 | HIGH | Client `src/lib`/`src/stores` <10% unit coverage | CLOSED since audit — large vitest suite exists (113 files); suite health tracked in P2 | -| 8 | HIGH | Unpinned critical npm packages | OPEN — review in P2 | +| 6 | HIGH | `Server/store/` untested | SUPERSEDED — `store/` was removed 2026-07-19 (single data layer, A-2026-07-05); its behavior lives in `db/` + sqlc (`db/dbgen/`), tested against in-memory SQLite | +| 7 | HIGH | Client `src/lib`/`src/stores` <10% unit coverage | CLOSED since audit — large vitest suite exists (164 files / ~4.4k tests as of 2026-08-04, ~94% statements); `src/lib` + `src/stores` are also mutation-tested (Stryker) | +| 8 | HIGH | Unpinned critical npm packages | RESOLVED 2026-08-05 — the dependency policy is decided and written down in docs/contributing.md (lockfiles authoritative + npm ci-only installs + weekly Dependabot with deliberate majors + per-PR audit gates); DC-11 was this finding's tracker | | 9 | MEDIUM | auth_handler bypasses service layer | OPEN — P4 consolidation candidate | -| 10 | MEDIUM | Audit-trail write failures silently ignored | OPEN — cheap fix, fold into P1 | -| 11 | MEDIUM | E2E not in CI / no .nvmrc | IN PROGRESS — nightly non-blocking e2e job planned in P2 | +| 10 | MEDIUM | Audit-trail write failures silently ignored | RESOLVED 2026-07-20 — every audit write routes through `db.WriteAudit` (`Server/db/audit.go:37`), which enqueues to the async writer and, on the synchronous fallback, logs failures via `slog.Error` with action/actor/target context. Nothing is silently dropped | +| 11 | MEDIUM | E2E not in CI / no .nvmrc | RESOLVED — better than planned: the full web e2e suite runs on every PR (`client-e2e`, non-blocking pending a flakiness soak) and the `@parity` subset gates merges (`client-e2e-parity`, blocking). Node is pinned by `setup-node` to 20 in CI | --- diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index 37febbf4..81a99a5a 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -26,7 +26,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-10 | MEDIUM | `api.NewRouter` god-constructor: builds services, hub, LiveKit, updater, admin, plugins; spawns goroutines; mounts everything | OPEN | | A-2026-07-11 | MEDIUM | `ws.Hub` mega-object with post-construction `Set*` wiring ("must be called before Run") | OPEN | | A-2026-07-12 | MEDIUM | Abandoned SolidJS beachhead still in-tree; `docs/client-architecture.md` describes the abandoned architecture | CLOSED 2026-07-19 — beachhead, adapters, build plugin, and Solid deps removed; client-architecture.md retired in favor of architecture/client.md | -| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal (correction 2026-07-19: `audit_log_v6` is only a transient rename inside migration 003, not a coexisting table) | OPEN | +| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal (correction 2026-07-19: `audit_log_v6` is only a transient rename inside migration 003, not a coexisting table) | RESOLVED 2026-08-04 — migration `029_drop_sounds_table.sql` drops the table; the client's orphan `getSounds`/`deleteSound` API methods (which called routes that never existed) were deleted in the same change | | A-2026-07-14 | LOW | Scattered client constants (`#5865F2` ×18, `localhost:8443` ×3); 64 timer call sites with manual lifecycle | OPEN | | A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | RESOLVED 2026-07-23 — staleness note added scoping the dead `store/`/Postgres references as historical; status header now records that only W2-4 and W3-3 remain open, with the doc as their tracker of record | | A-2026-07-16 | HIGH | Server-wide permission rule hand-rolled at 2 sites (`RequirePermission` raw any-of bit test; `ModerationService`); channel-level `deny` silently dropped — and cached for 30s — when the override fetch errors, at 2 of 5 sites | RESOLVED 2026-07-23 (D13) — `permissions.HasServerPerm` now owns the server-scoped rule (both sites collapse onto it; multi-bit masks are all-of); both override-fetch sites fail closed (`getOrPopulate` skips the fetch for admins, denies and caches nothing on error; `ListVisibleChannels` returns `ErrInternal`); the fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`. Locked by failing-first tests. See [plans/permission-middleware-consolidation.md](plans/permission-middleware-consolidation.md) | @@ -55,7 +55,7 @@ audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md). | 7 | HIGH | Client unit coverage | Suite is large (157 test files) and green; flipping `client-tests` to blocking is backlog #10 — see A-2026-07-04 | | 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open** — `Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` | | 10 | MEDIUM | Audit-trail write failures silently ignored | **RESOLVED 2026-07-20 (D9)** — best-effort audit writes are kept as the convention, but no longer silently discarded: every `LogAudit` call site now routes through the shared `db.WriteAudit` helper (`db/audit.go`), which logs a failed write with actor/action/target context without failing the request. All ~26 call sites across admin/api/ws/service converted from `_ = LogAudit(...)`; pinned by `db/audit_test.go` | -| 11 | MEDIUM | E2E not in CI | **Confirmed open** — no Playwright job exists in `.github/workflows/ci.yml` | +| 11 | MEDIUM | E2E not in CI | **RESOLVED since (verified 2026-08-04)** — `ci.yml` now has `client-e2e` (full web suite, every PR, non-blocking pending soak) and the **blocking** `client-e2e-parity` job | | W3-4 (remediation plan) | LOW | Contradictory upload cache header | **Fixed 2026-07-19** — now `private, no-cache` per the remediation plan's prescription | --- @@ -171,7 +171,7 @@ Ranked by severity × effort; quick wins float within tier. S/M/L ≈ hours / da | ~~7~~ | ~~Stop discarding `LogAudit` errors; fix the contradictory upload `Cache-Control`~~ **DONE 2026-07-20 (D9)** — cache header fixed 2026-07-19; `db.WriteAudit` helper now covers all 26 call sites repo-wide | prior #10, W3-4 | MEDIUM | S | | ~~8~~ | ~~Remove the SolidJS beachhead + adapters; retire `docs/client-architecture.md`~~ **DONE 2026-07-19** | A-2026-07-12 | MEDIUM | S | | ~~9~~ | ~~Resolve the `protocol-schema.json` ghost~~ **DONE 2026-07-19** — real codegen (`genprotocol`) + `make protocol-verify` CI gate | A-2026-07-08 | MEDIUM | M | -| 10 | **PARTIAL 2026-07-20** — Green + blocking client unit suite; nightly Playwright. Suite proven green (3296/3296 on merged main), A-2026-07-04 closed. **Still open:** flip `client-tests` to blocking in `ci.yml`; add the nightly Playwright job. Deferred while GitHub Actions minutes are depleted | A-2026-07-04 | HIGH | M | +| 10 | **DONE (verified 2026-08-04)** — Suite proven green (3296/3296 on merged main; 4394/4394 at `5630aa1`), A-2026-07-04 closed. `client-tests` is blocking (no `continue-on-error` in `ci.yml`), and Playwright runs on every PR: `client-e2e` (full suite, non-blocking pending soak) + blocking `client-e2e-parity` | A-2026-07-04 | HIGH | M | | ~~11~~ | ~~Finish the V2 dispatch migration; delete V1~~ **DONE 2026-07-20 (D10)** — last 3 V1 types ported to V2; V1 registry + fallback deleted; parity guard test added | A-2026-07-09 | MEDIUM | M/L | | 12 | Consolidate DB access behind the service layer (start with auth routes, prior #9); then Hub constructor cleanup and decomposition | A-2026-07-06, -10, -11 | MEDIUM | L | diff --git a/docs/audit-2026-08-04-docs-and-coverage.md b/docs/audit-2026-08-04-docs-and-coverage.md new file mode 100644 index 00000000..6b764e1a --- /dev/null +++ b/docs/audit-2026-08-04-docs-and-coverage.md @@ -0,0 +1,662 @@ +# OwnCord Audit — Documentation Accuracy & UI/UX Test Coverage (2026-08-04) + +**Audited tree:** `5630aa1` (= `dev` at audit time) +**Scope:** documentation/spec accuracy against the code, a UI/UX flow +inventory with test-coverage status, real test-suite executions, and +reconciliation of the four prior audits and eleven plan documents. +**Relation to `audit-2026-08-04.md`:** same date, disjoint scope — that +document is the whole-codebase *security* review (its three findings +A-2026-08-01/02/03 are referenced here as open, not re-litigated). +**Method:** every claim below was verified in the code at `5630aa1` and cites +`file:line` as of that commit. Every test result was produced by an actual +run in this audit session (§3); nothing is inferred from CI badges. +**Fixes:** unlike a read-only review, this audit *shipped* its documentation +fixes in the same branch — §5 lists what changed per document. + +--- + +## 1. Executive summary + +The documentation set was healthier at the reference layer than at the +architecture layer. `schema.md`, `system-overview.md`, `credential-storage.md` +and the UX messaging spec were essentially current; `api.md`/`protocol.md`/ +`server-configuration.md` carried a handful of wrong numbers (one +security-relevant: the login rate limit documented as 60/min vs the enforced +5/min) and a large additive gap (the `/admin/api` surface); and the five +architecture pages were badly stale — all stamped "verified 2026-07-19" and +still asserting, among other things, that the protocol-schema codegen pipeline +did not exist and that the client HTTP path was unpinned, both the opposite of +reality. All of that is fixed in this branch. + +Every runnable test suite is green at `5630aa1`: Go race + deadlock, client +unit/integration (4394 tests, 94.66% stmt coverage), Rust lib + clippy, the +full Playwright web e2e suite (**270/270**), the blocking `@parity` subset +(15/15), and the browser smoke suite. Coverage is deep for messaging, +channels, DMs, voice (mocked), and settings; the four flows with **no +end-to-end coverage at all** are the cert-TOFU trust flow, E2EE identity +verification, the admin panel, and the updater — each unit-tested but never +exercised as a user journey. + +The prior audits needed three status reconciliations (all applied): the HIGH +e2e-suite breakage T-2026-07-25-21 was fixed but never closed; two 2026-07-19 +rows still claimed no Playwright CI job exists; and two of the four F3 +security-scan follow-ups (safety-number rendering, `rePinPeerIdentity` UI) +have shipped without their plan being updated. One 2026-04-07 finding (#8, +npm dependency pinning) was orphaned — never carried into any later audit — +and is resurfaced here as DC-11. + +--- + +## 2. Architecture summary (as verified) + +**Server** — one Go 1.26 binary (~42k LOC prod, ~71k LOC test). +`main.go` wires config (koanf: defaults → `config.yaml` → `OWNCORD_*` env), +TLS (self-signed/ACME/manual), SQLite (pure-Go, single writer), migrations +001–028, the WASM plugin registry (execution gated behind `-tags wazero`), +and hands off to `api.NewRouter` (`Server/api/router.go:35`) — the +composition root that builds the middleware chain (9 entries incl. an opt-in +Coraza WAF), mounts ~91 REST routes under `/api/v1` + `/admin/api`, and +constructs the `ws.Hub`. The ws package (~9.5k LOC, the largest) implements +typed V2-only command dispatch (V1 deleted), topic pub/sub with three-tier +priority queues, and 3-tier reconnect replay (ring buffer 1000 → events table +≤5000 → full `ready`). Voice is LiveKit (optional managed subprocess, +checksum-verified auto-download, 5-min scoped JWTs); voice E2EE is +client-side ECDH with the server acting only as an announce/offer relay and +key-holder tracker (`Server/ws/voice_e2ee.go`) plus long-term identity keys +(`users.identity_public_key`, migration 017). Wire types are **generated**: +`docs/protocol-schema.json` → `Server/scripts/genprotocol` → Go + TS +constants, CI-gated (`make protocol-verify`) — except the hand-declared +plugin command family (`chat_command`/`command_reply`/`plugin_broadcast`, +`Server/ws/handlers_command.go:22,117,135`), which bypasses the gate (DC-01). + +**Client** — Tauri v2: ~42k LOC vanilla TS + ~4.7k LOC Rust in 16 modules, +29 IPC commands. All three network paths (WS, REST, LiveKit) terminate TLS in +Rust proxies sharing one TOFU core (`src-tauri/src/tofu.rs`) where *deciding +never writes a pin*: first contact is rejected until the user confirms via a +blocking modal; mismatches likewise. Credentials live in the OS keyring with +read-back verification and an encrypted-file fallback. The TS side is a +hand-rolled reactive store (9 store singletons), a 34-handler dispatcher as +the single WS fan-in, imperative-DOM component factories (~60 component +files), and a 2-page router (Connect/Main). Voice adds RNNoise noise +suppression, PTT via a native key poller, device hot-swap, screen share, and +an E2EE verification surface (roster shield badge → identity-mismatch modal → +TOCTOU-safe re-pin, `ChannelSidebar.ts:45-135`). + +**Test infrastructure** — Go: 224 `_test.go` files (race + deadlock-tag runs +in CI; `-tags wazero`/`-tags otel` tests never run anywhere, T-2026-07-25-16). +Client: 167 vitest files (70% coverage gate, blocking), 37 web Playwright +specs (270 tests; full run non-blocking in CI, `@parity` subset blocking), +11 native specs (Windows-only, not in CI, 3 of them matched by no config), +1 browser-mode smoke, Stryker (manual). Rust: 83 `cargo test --lib` tests + +clippy `-D warnings`, blocking. + +--- + +## 3. Test-run results (this session, at `5630aa1`) + +Environment: Linux container, Go 1.26 (auto-fetched via `GOTOOLCHAIN=auto`; +host go 1.24.7), Node 22.22.2 (**CI pins Node 20** — one version-skew to keep +in mind), npm 10.9.7, Rust 1.94.1, Playwright chromium via a revision bridge +(preinstalled build 1194 symlinked to the expected 1234 layout; Chromium +141.0.7390.37), `xvfb` for the headed browser-mode run. + +| Suite | Command | Result | Wall time | +| ----- | ------- | ------ | --------- | +| Go build (all tags implied by tests) | `go build ./...` | OK | (toolchain+module download dominated) | +| Go tests, race | `go test -race -timeout 20m ./...` | **PASS** — 14 packages ok, 0 failures | 5m35s | +| Go tests, deadlock detector | `go test -tags deadlock -count=1 ./...` | **PASS** | 2m03s | +| gofmt | `gofmt -l .` (Server/) | clean — no files | <1s | +| go vet | `go vet ./...` | **PASS** | ~30s | +| Client typecheck | `npx tsc --noEmit` | **PASS** | ~40s | +| oxlint | `npx oxlint src/` | **PASS** | <5s | +| ESLint | CI's generated-file patch, then `npx eslint src/` (patch reverted after) | **PASS** | ~50s | +| Prettier | `npx prettier --check "src/**/*.ts" "tests/**/*.ts"` | **PASS** | ~15s | +| Client unit+integration | `npx vitest run --coverage` | **PASS** — 167 files, **4394/4394**; coverage 94.66% stmts / 92.07% branch / 93.77% funcs (gate: 70%) | 3m04s | +| Client production build | `npm run build` (tsc + vite) | **PASS** | 26s | +| knip | `npx knip` | exit 1 — 1 unused export (`incrementDmMention`, `src/stores/dm.store.ts:181`) + 4 config hints; **does not flag the dead modules** (§7) | ~20s | +| npm audit (prod deps) | `npm audit --omit=dev --audit-level=high` | **0 vulnerabilities** | ~5s | +| Rust lib tests | `cargo test --lib` | **PASS** — 83/83 (Windows-only DPAPI tests excluded by cfg on Linux) | cold build ~8m, tests 0.09s | +| cargo clippy | `cargo clippy --all-targets -- -D warnings` | **PASS** | ~3m | +| Playwright web e2e (full) | `CI=1 npx playwright test --config=playwright.config.ts` | **PASS — 270/270**, no flaky retries | 8m40s | +| Playwright `@parity` | `CI=1 npx playwright test --grep "@parity"` | **PASS — 15/15** (mirrors the blocking CI job) | 35s | +| Browser-mode smoke | `xvfb-run -a npm run test:browser` | **PASS — 2/2** | 17s | + +Not run, with reasons (no results are claimed for these): + +| Suite | Why not | Compensating evidence | +| ----- | ------- | --------------------- | +| Native Playwright (`playwright.config.native.ts`) | Drives a built Windows Tauri exe over CDP/WebView2 — impossible on this Linux container | None in CI either — deliberately manual (see `tests/e2e/E2E-ISSUES.md`) | +| Tauri full bundle (`npm run tauri build`) | Packaging run not attempted (webkit dev libs installed, but bundle output wasn't needed for a docs audit) | CI `tauri-build` job on PRs to `main` | +| golangci-lint | Not installed locally; CI-only by design | Blocking `server-build-test` step (`ci.yml:87`) | +| `go test -tags wazero` / `-tags otel` | Same gap CI has — these tests run nowhere (T-2026-07-25-16, still open) | None — that is the finding | +| Stryker mutation | Manual-only by design, hours-long | `stryker.config.mjs` thresholds | + +Flake rule used: a spec is *flaky* only if it failed then passed on retry +within a run, or failed in exactly one of two runs; a spec failing every +attempt is *failing*, named by file. No suite in this session needed the +distinction — there were zero retries and zero failures. + +--- + +## 4. UI/UX flow coverage matrix + +Status legend: **covered** = spec-conformant implementation + meaningful unit +*and* e2e coverage · **partial** = implemented + unit-tested but no (or thin) +end-to-end journey · **untested** = no automated coverage · **broken** = does +not do what the spec says (none found). + +| # | Flow | UX spec | Implementation | Unit tests | Web e2e | Status | +|---|------|---------|----------------|-----------|---------|--------| +| 1 | Login (password) | connection-and-auth §2.2 | `pages/connect-page/LoginForm.ts:12` | `connect-page`, `auth.store` | `connect-page.spec.ts` (17) + native `auth-flow` | covered | +| 2 | Register by invite | connection-and-auth §3 | `LoginForm.ts` register branch | `register` paths in unit suite | `register-flow.spec.ts` (10) | covered | +| 3 | TOTP challenge | connection-and-auth §2.2 | `LoginForm.ts` `totp` state | `totp-settings` | `totp-flow.spec.ts` (5) | covered | +| 4 | Logout | connection-and-auth §7 | `lib/logout.ts`, `main.ts:592` | `logout` | `logout-flow.spec.ts` (2) | covered | +| 5 | **Cert TOFU first-use + mismatch** | connection-and-auth §5 | `main.ts:146-208`, `src-tauri/src/tofu.rs`, `CertMismatchModal.ts` | `cert-first-use-modal`, `cert-mismatch-modal`, `ws-cert`, `http-proxy`; Rust `tofu.rs` (12) | **none** | **partial — headline gap** | +| 6 | Server profiles (add/delete/auto-connect) | connection-and-auth §2.1 | `connect-page/ServerPanel.ts:53-317`, `lib/profiles.ts` | `profiles`, `server-panel` | `connect-settings.spec.ts` (3) | covered | +| 7 | Server quick-switch | connection-and-auth §6 | `QuickSwitchOverlay.ts`, `SidebarArea.ts:698` | `components/QuickSwitchOverlay` | switcher cases in `overlays.spec.ts` | covered | +| 8 | Reconnect banner + recovery | README §3 | `ServerBanner.ts`, `ws.ts` backoff | `ws-reconnect`, `ws-lifecycle`, `server-banner` | `reconnection.spec.ts` (6), `banners-toasts.spec.ts` (5) | covered | +| 9 | Channel sidebar (list/switch/categories) | channels-members-dms §1 | `ChannelSidebar.ts` | `channel-sidebar*`, `channel-controller` | `channel-sidebar.spec.ts` (10), `channel-switch-messages.spec.ts` (4) + native `channel-navigation` | covered | +| 10 | Channel create/edit/delete | settings-and-admin §3 | `Create/Edit/DeleteChannelModal.ts`, `SidebarArea.ts:16-18` | `create/edit/delete-channel-modal` | none dedicated | partial | +| 11 | Channel reorder (drag) | channels-members-dms §1.3 | `channel-sidebar/drag-reorder.ts` | `drag-reorder` (listener leak fixed 2026-08-05; the lifecycle tests now pin signal-ownership) | none | partial | +| 12 | Per-channel mutes | channels-members-dms §1.1a | `lib/channel-mutes.ts`, context menu | `channel-mutes`, `channel-mute-ui` | `gating-badges.parity.spec.ts` | covered | +| 13 | Message send (optimistic) | messaging §1-2 | `ChannelController.ts`, `messages.store.ts:225-279` | `messages.store`, `message-input`, `message-controller` | `message-send-flow` (5), `message-input` (8), `message-list` (6) + native `chat-operations` | covered | +| 14 | Edit / delete (two-click) | messaging §4-5 | `MessageInput.ts:589`, `MessageController.ts:24` | store + controller units | `message-edit-delete.spec.ts` (5) | covered | +| 15 | Reactions | messaging §6 | `message-list/reactions.ts`, `ReactionController.ts` | `reaction-controller`, `reaction-tooltip` | `message-actions.spec.ts` (12) | covered | +| 16 | Replies | messaging §7 | `MessageInput.ts:577`, `renderers.ts` | renderer units | `reply-flow.spec.ts` (5) | covered | +| 17 | Pins | messaging §9 | `PinnedMessages.ts`, `OverlayManagers.ts:202` | `pinned-messages` | pins cases only in **native** `overlays.spec.ts` | partial | +| 18 | Typing indicator | channels-members-dms §2.1 | `TypingIndicator.ts:20-25` | typing units | `typing-indicator{,-ws}.spec.ts` (7) | covered | +| 19 | Embeds / link previews | messaging §8 | `message-list/embeds.ts`, `media.ts` | `embeds`, `media*` | rich-content cases in `message-list.spec.ts` | partial | +| 20 | File upload | messaging §3 | `MessageInput.ts:372-571` (NOT the dead `FileUpload.ts`) | `attachments-*`, `file-upload` (tests the dead module) | upload-blocked cases in `message-input.spec.ts` | partial | +| 21 | GIF picker | messaging §3a | `GifPicker.ts`, `lib/gifProvider.ts` | `gif-picker`, `gif-provider` | none | partial | +| 22 | Emoji (picker/autocomplete/custom) | messaging §3b | `EmojiPicker.ts`, `EmojiAutocomplete.ts`, `custom-emoji.ts` | `emoji-*`, `custom-emoji` | `emoji-insertion.spec.ts` (3), `emoji-voicemod.parity.spec.ts` | covered | +| 23 | Mentions (+badges) | messaging §10 | `lib/mentions.ts`, `MentionAutocomplete.ts` | `mention-*` incl. property tests | `gating-badges.parity.spec.ts` | covered | +| 24 | Markdown/code rendering | messaging §8 | `message-list/markdown.ts`, `formatting.ts`, `syntax-highlight.ts` | `content-markdown`, `markdown-parser.property`, `safe-render` | rich-content cases | covered | +| 25 | Search | messaging §11 | `SearchOverlay.ts` | `search-overlay` | none | partial | +| 26 | Jump-to-message / deep links | messaging §12 | `MessageJump.ts`, `lib/deep-link.ts` | `message-jump`, `deep-link{,-init}` | none | partial | +| 27 | Read state / NEW divider / unread | messaging §13 | `lib/read-state.ts`, `MessageList.ts:91` | `read-state`, `message-list-new-divider` | unread-badge case in `channel-switch-messages.spec.ts` | covered | +| 28 | DMs (1:1) | channels-members-dms §3 | `SidebarDmSection.ts`, `dm.store.ts` | `dm-*` cluster | `dm-system.spec.ts` (9); native `dm-system` is **orphaned** (DC-03) | covered | +| 29 | Group DMs | channels-members-dms §3.1a | `MemberPickerModal.ts`, `DmSidebar.ts:240-251` | `member-picker-*`, `dm-groups` | `social.parity.spec.ts` (create/leave) | covered | +| 30 | Blocking | channels-members-dms §3.2 | `SidebarMemberSection.ts:177-186`, `blocks.store` | `blocks-store` | none | partial | +| 31 | Member list / presence / roles | channels-members-dms §2 | `MemberList.ts`, `members.store` | `members.store`, `sidebar-member-section` | `member-list.spec.ts` (6) | covered | +| 32 | Profile popup | channels-members-dms §2.2 | `UserProfilePopup.ts` (mounted from `MemberList.ts`) | `user-profile-popup` | none | partial | +| 33 | Status picker / auto-idle | README §3 | `StatusPicker.ts`, `lib/autoIdle.ts` | `status-picker-*`, `auto-idle`, `user-status` | `user-bar.spec.ts` (7) | covered | +| 34 | Voice join/leave/mute/deafen | voice-and-e2ee §1-3 | `lib/livekitSession.ts`, `VoiceCallbacks.ts`, `VoiceWidget.ts` | 24-file voice cluster | `voice-lifecycle` (24), `voice-channel` (11), `voice-widget` (5) + native `voice-controls` | covered *(mocked LiveKit — no real-media e2e anywhere)* | +| 35 | Push-to-talk | voice-and-e2ee §4 | `lib/ptt.ts` + `src-tauri/src/ptt.rs` | `ptt` + Rust `ptt.rs` (7) | none (needs native input) | partial | +| 36 | Noise suppression / audio pipeline | voice-and-e2ee §8 | `lib/noise-suppression.ts`, `lib/audioPipeline.ts` | `rnnoise-worklet`, `audio-pipeline-*` (lib itself is coverage-excluded with rationale) | none | partial | +| 37 | Video grid / screen share | voice-and-e2ee §5 | `VideoGrid.ts`, `lib/screenShare.ts` | `video-grid`, `screen-share-*`, `stream-preview` | camera/share cases in `voice-lifecycle.spec.ts` | partial | +| 38 | **E2EE securing + identity verification** | voice-and-e2ee §7 | `lib/livekitE2EE.ts`, `ChannelSidebar.ts:45-135`, `CertMismatchModal.ts:221` | `e2eeCrypto`, `livekit-e2ee`, `identity`, `identity-mismatch-modal` | `voice-e2ee-verify.spec.ts` (6, added 2026-08-05) | covered *(was a headline gap)* | +| 39 | DM calls (ring) | voice-and-e2ee §9 | `lib/call-ring.ts`, `IncomingCallBanner.ts`, `MainPage.ts:527` | `call-ring` | none | partial | +| 40 | Quick switcher (Ctrl+K) | channels-members-dms §1 | `QuickSwitcher.ts`, `GlobalKeybinds.ts` | `quick-switcher`, `global-keybinds` | `overlays.spec.ts` (26) + native `overlays` | covered | +| 41 | Context menus | README §2 | `lib/context-menu.ts:31`, `AdminActions.ts:163/412` | `context-menu`, `admin-actions` | role-change case in `social.parity.spec.ts` | covered | +| 42 | Toasts | README §2 | `Toast.ts`, `lib/toast.ts:31` | `toast`, `toast-coverage` | `toast.spec.ts` (5) | covered | +| 43 | Desktop notifications | README §2 | `lib/notifications.ts:37` | `notifications` | none (OS-level) | partial | +| 44 | NSFW gate | channels-members-dms §1.1 | `NsfwGate.ts`, `lib/nsfw-gate.ts` | `nsfw-gate` | `gating-badges.parity.spec.ts` | covered | +| 45 | Settings overlay (9 tabs incl. Accessibility) | settings-and-admin §1 | `SettingsOverlay.ts:102-110` + `components/settings/*` | per-tab tests incl. `accessibility-tab`, `os-motion` | `settings-overlay.spec.ts` (24), `theme-persistence.spec.ts` (7) + native | covered | +| 46 | Invites | settings-and-admin §3 | `InviteManager.ts` | `invite-manager` | invite cases in `overlays.spec.ts` | covered | +| 47 | Inline moderation (kick/ban+reason/role) | settings-and-admin §3 | `AdminActions.ts:99-330`, `SidebarMemberSection.ts` | `admin-actions`, `admin-panel` | `social.parity.spec.ts` | covered | +| 48 | **Admin panel (web, 14 sections)** | settings-and-admin §3.1 | `Server/admin/static/index.html` + `/admin/api` | Go: 23 `_test.go` files in `Server/admin/` | **none — no browser automation at all** | **partial — headline gap** | +| 49 | **Updater UI** | settings-and-admin §5 | `UpdateNotifier.ts`, `lib/updater.ts`, `update_commands.rs` | `updater`, `update-notifier` + Rust (2) | `updater.spec.ts` (4, added 2026-08-05) | covered *(was a headline gap)* | +| 50 | System tray | settings-and-admin §6 | `src-tauri/src/tray.rs` | none (no Rust tests; TS side untestable without native menu) | none | **untested** | +| 51 | Health status indicator | connection-and-auth §2.1 | connect-page health polling | `connect-page` units | `health-status.spec.ts` (2) | covered | +| 52 | Logs tab / log purge | settings-and-admin §1 | `settings/LogsTab.ts`, `lib/logPersistence.ts`, `purge-prompt.ts` | `logs-tab`, `log-persistence` | none | partial | + +**Reading the matrix:** 30 flows fully covered, 21 partial, 1 untested, +0 broken. The four headline gaps (rows 5, 38, 48, 49) share a shape: they are +the client's *security- and lifecycle-critical* flows, exactly the ones where +a regression is least visible in day-to-day dev use. + +### UX problems beyond test coverage + +- **Dead-but-tested modules** mislead readers of the test suite: + `ServerStrip.ts`, `FileUpload.ts`, `lib/reconcile.ts` and `src/generated/` + are imported by nothing in `src/` yet keep green test files + (`server-strip.spec.ts` even runs in every e2e pass). §7. +- ~~**No toast on active-channel deletion**~~ **CLOSED 2026-08-05 (DC-12)** — + the `channel_delete` handler toasts "This channel was deleted" alongside + the redirect; non-active deletions stay silent. +- ~~**No optimistic reaction toggle**~~ **CLOSED 2026-08-05 (DC-12)** — the + pill toggles on click under the send's correlation id; the self-echo is + consumed, an error reply or transport failure rolls back exactly that + toggle (`addOptimisticReaction`/`rollbackReaction`). +- ~~**No slow-mode countdown in the composer**~~ **CLOSED — was already + implemented** (verified 2026-08-05): `ChannelController.startSlowMode` + drives a ticking composer reason from the ready payload's `slow_mode`; + the audit's gap note reflected a stale spec callout, now flipped. +- ~~**No in-flight state on destructive admin actions**~~ **CLOSED + 2026-08-05 (DC-12)** — `withConfirmation` already carried the pending + state; the residual double-fire (role-change submenu) is now guarded too. +- ~~**Known bug (pinned by its own test):** `drag-reorder.ts` listener + ref-count~~ **FIXED 2026-08-05 (DC-12)** — the per-row ref-count is + replaced with per-sidebar AbortSignal ownership (idempotent per signal, + released on abort); the pinning test now pins the fixed contract. +- ~~**Accessibility:** … untracked debt~~ **CLOSED 2026-08-05 (DC-13)** — + systematic pass shipped: `lib/a11y.ts` (dialog semantics, focus trap, + focus restore, roving tabindex) applied through `modalFactory` and every + hand-rolled modal/overlay, tablist semantics on SettingsOverlay, + combobox wiring on QuickSwitcher and the composer autocompletes, + listbox/option + roving tabindex on the pickers, and polite live regions + for toasts/typing; +71 unit cases and an axe-style e2e smoke + (`a11y-smoke.spec.ts`). + +--- + +## 5. Documentation accuracy findings (fixed in this branch) + +| Doc | Verdict before | Highest-stakes drift | Fixed in | +| --- | -------------- | -------------------- | -------- | +| `docs/api.md` | minor drift + big gap | Login rate limit said **60/min**, code enforces **5/min** (`Server/api/constants.go:19`); 24 `/admin/api` endpoints had no reference section | C1 `df12147` | +| `docs/protocol.md` | moderate drift | Type counts wrong (24→26, 33→37); voice join/leave limit said "None" (is 5/1s, `ws/voice_join.go:22`); E2EE offer said 5/1s (is 64/1s, `ws/voice_e2ee.go:23`); plugin command family undocumented | C1 `df12147` | +| `docs/server-configuration.md` | moderate drift | 3 WAF keys + `database.type` + `telemetry.otlp_insecure` + entire `logging` section missing; plugin-disabled status said 501 (is 503, `api/plugins_handler.go:51`) | C1 `df12147` | +| `docs/deployment.md` | minor drift | `/health` sample still showed the removed `version` field; stale build version | C1 `df12147` | +| `docs/schema.md` | **current** | none — verified 10/10 spot checks incl. migrations table through 028 | no change needed | +| `docs/architecture/websocket.md` | badly stale | Claimed `protocol-schema.json` **does not exist** — it is the CI-gated codegen source (`Server/scripts/genprotocol`) | C2 `28f66fb` | +| `docs/architecture/server.md` | badly stale | Wrong WS dependency (`nhooyr.io` → `github.com/coder/websocket`, `go.mod:8`); LOC 44-55% low; migrations 016→028 | C2 `28f66fb` | +| `docs/architecture/data-model.md` | badly stale | Claimed migrations 001-015 & 23 tables (actual: 028 & 26); told readers `schema.md` was 6 migrations behind when it is current | C2 `28f66fb` | +| `docs/architecture/voice-e2ee.md` | stale callout | Claimed the E2EE flow is absent from `protocol.md` (it has a full section) | C2 `28f66fb` | +| `docs/architecture/client.md` | badly stale | Described the deleted Solid beachhead; claimed the HTTP path is **unpinned** when `http_proxy.rs` pins via the shared `tofu.rs`; listed a deleted `roles` store | C2 `28f66fb` | +| `docs/architecture/system-overview.md` | current | — (re-stamped only) | C2 `28f66fb` | +| `docs/architecture/ux/*` (6 files) | minor drift | Cert first-use described as an 8s banner on `trusted_first_use`; reality is a blocking modal on `first_use` with reject-until-confirmed (`main.ts:146-176`); two "remaining gaps" (block button, ban reason) had already shipped; several whole features undocumented (group DMs, channel mutes, DM ring, E2EE verify UI, tray) | C3 `8c8a4a5` | +| `docs/security.md` | minor drift | "Hardcoded **Tenor** API key" limitation was doubly wrong (provider is Klipy, key is server-side); audit-log action list claimed `backup_restore` is logged (it is not — no `WriteAudit` on the restore path); firewall checklist omitted the LiveKit media ports | C4 `a31c555` | +| `docs/credential-storage.md` | current | one casing nit (`"keyring"` vs serialized `"Keyring"`) | C4 `a31c555` | +| `docs/quick-start.md`, `README.md` | minor drift | Stale build versions (1.0.0 / 1.1.0-alpha.3 → 1.2.0-alpha.1); README's "latest audit" pointer | C4 `a31c555` | +| `docs/contributing.md` | minor drift | sqlc rows claimed a PostgreSQL engine + `pgdbgen` (removed); protocol targets missing | C4 `a31c555` | +| `docs/plans/*` (11 files) | mixed | statuses verified & stamped; see §6 | C5 `064a8c6` | +| `Client/tauri-client/tests/e2e/E2E-ISSUES.md` | 4.5 months stale | Claimed 209/209 (suite is 270); pointed at a nonexistent `docs/brain/` plan | C8 `940377c` | +| `CHANGELOG.md` | gap | No Unreleased section; three post-release fixes unrecorded | C7 `627e658` | +| `docs/livekit-setup.md`, `tailscale.md`, `port-forwarding.md`, `mcp-introspect.md`, `client-architecture.md` (stub), `SECURITY.md` | current | cross-references verified; no drift found worth an edit | no change | + +--- + +## 6. Prior-audit & plan reconciliation + +### audit-2026-08-04.md (security review, same day) + +All three findings verified **still open** at `5630aa1` — nothing in this +docs-only branch changes them: + +- **A-2026-08-01 (HIGH):** `handleDeleteChannelPermission` lacks the + hierarchy/grantability guards its PUT twin has + (`Server/admin/handlers_channel_perms.go:180` vs `:141-150`). +- **A-2026-08-02 (HIGH):** admin channel LIST/PATCH/DELETE lack the + `type == "dm"` guard (`Server/admin/handlers_channels.go:38/159/237`). +- **A-2026-08-03 (MEDIUM):** `DMService.RingTargets` skips the block check + every sibling DM sink performs (`Server/service/dm.go:336`). +- Its §5 observation also verified: `handleRestoreBackup` hardcodes + `data/chatserver.db`, ignoring `cfg.Database.Path` + (`Server/admin/handlers_backup.go:177`). + +### audit-test-coverage-2026-07-25.md + +- **T-2026-07-25-21 (HIGH)** — closed this branch (C6): the e2e suite was + repaired but the audit never updated; re-verified 270/270 locally. +- Still open, re-confirmed: **-16** (wazero/otel-tagged tests never run — the + single highest-leverage CI change), **-17** (`main.go`/seed untested), + **-18** (`MainPage.ts`/`main.ts` coverage-excluded), **-19** (no Go/Rust + coverage floor, deliberate), **-20** (unreproduced `-coverpkg` flake). +- Its §4 bugs: `logctx.WithGroup` nesting — not re-tested here; + `drag-reorder` ref-count — still present (KNOWN BUG comment). + +### audit-2026-07-19.md + +- Item 11 ("E2E not in CI") and backlog #10 — closed this branch (C6), + both were stale. +- Still open, re-confirmed: **A-2026-07-10** (router god-constructor), + **A-2026-07-11** (`ws.Hub` mega-object with `Set*` post-construction + wiring — the temporal coupling note in `websocket.md` still applies), + **A-2026-07-13** (dead `sounds` table), **A-2026-07-14** (scattered client + constants). + +### audit-2026-04-07.md + +- **#8 (HIGH, unpinned critical npm packages)** — orphaned: marked + "review in P2" and never carried into any later audit. Resurfaced here as + **DC-11**. (Today's `npm audit --omit=dev`: 0 vulnerabilities, and the + repo commits `package-lock.json`, so the practical exposure is bounded — + but the finding was never dispositioned.) +- **#9 (auth_handler bypasses service layer)** — still open + (`api/router.go:104` passes `database` to `MountAuthRoutes`). +- **#11** — E2E-in-CI half closed; **`.nvmrc` still absent** (CI pins + Node 20 inline; this session ran Node 22 — the skew is real, DC-10). + +### docs/plans (11 files — statuses stamped in C5) + +Shipped: decisions record, channel-visibility-unification, http-tofu-proxy, +permission-middleware-consolidation (disclosed `channelCanSend` copy still +open at `serve_ready.go:119`), security-hardening-remediation, sqlc-adoption, +v2-dispatch-migration, tauri-capability-narrowing (DNS-rebinding follow-up +open), **discord-parity** (all six Phase 1 rows verified shipped — including +archived channels, which recon initially mis-reported: they are filtered by +`permissions/checker.go:116-121`), **security-scan-2026-07-22** (8/8 closed; +two of four F3 follow-ups have since shipped — safety number rendered, +`rePinPeerIdentity` wired at `ChannelSidebar.ts:125`; `getIdentityPin` +fail-open remains, DC-08). Design-only: slash-commands (staleness notes +added — migration 016 taken, `Server/store/` gone). + +--- + +## 7. Dead code & unused dependencies + +`npx knip` (real output this session): 1 unused export +(`incrementDmMention`, `src/stores/dm.store.ts:181` — zero callers anywhere, +tests included) + 4 config hints. **Knip cannot flag the dead modules below:** +its vitest/playwright plugins treat test files as entries, so a module whose +only importer is its own test is "used" by construction. That is exactly the +zombie pattern all four exhibit: + +| Module | LOC | Evidence of death | Kept alive by | +| ------ | --- | ----------------- | ------------- | +| `src/components/ServerStrip.ts` | 140 | `SidebarArea.ts:4` comment: "The ServerStrip has been removed in favor of…"; zero imports in `src/` | `tests/unit/server-strip.test.ts`, `tests/e2e/server-strip.spec.ts` (runs in every e2e pass) | +| `src/components/FileUpload.ts` | 231 | zero references in `src/`; real upload lives in `MessageInput.ts` | `tests/unit/file-upload.test.ts` | +| `src/lib/reconcile.ts` | — | only `tests/unit/reconcile.test.ts` imports `reconcileList` | its test | +| `src/generated/**` | 259 | zero imports in `src/` or `tests/`; generated 2026-04-03, covers only 21 of 29 IPC commands; CI regenerates it (`ci.yml:418-439`) into a directory no bundle reads; `build.rs` never invokes typegen locally | CI regeneration ritual | + +Also in this category server-side: the `sounds` table (dead schema, +documented as such), `voice_speakers` (reserved, never emitted), +`voice_config.bitrate`, and the macOS PTT stub — all already inventoried in +`docs/plans/discord-parity.md` §"still-dead code". + +Deletion is deliberately **not** done in this branch (docs-only diff) — +DC-05 below is the decision request. + +--- + +## 8. TODO/FIXME inventory + +The entire codebase carries **five** TODO/FIXME markers — remarkably clean: + +| # | Location | Text (condensed) | Assessment | +| - | -------- | ---------------- | ---------- | +| 1 | `Server/ws/voice_e2ee.go:167` | "consider re-checking key-holder status inside sendToUserIfInVoiceChannel" | **security-adjacent** — sits on the E2EE relay path the F3 work hardened; worth a decision | +| 2 | `Client/.../src/pages/main-page/SidebarArea.ts:600` | `TODO(H16)`: O(n) DOM thrash on rebuild | perf; the only ID-tagged TODO | +| 3 | `Server/admin/update_handlers.go:35` | "maybe disable this endpoint in future docker build type?" | ops hardening; distroless image makes self-update pointless in-container | +| 4 | `Server/ws/deps.go:270` | "consider replacing 'deps any' with generics" | type-safety debt from the V2 migration | +| 5 | docs prose | non-actionable | — | + +One **stale code comment** found (not a TODO): `Server/ws/serve_ready.go:141` +claims the ready payload carries "no slow_mode, archived, voice_* extras" — +but `channelPayloadFrom` (`ws/messages.go:272-284`) ships `slow_mode`, +`nsfw`, `voice_max_users`, `voice_max_video`. The comment predates +discord-parity Phase 5. Code comment → not fixable in a docs-only diff +(DC-09). + +--- + +## 9. Prioritized gap list + +**P0 — broken:** none found. Every spec'd flow that exists works as +specified, and every suite is green. + +**P1 — significant risk or misleading state** + +- **DC-01** ~~Extend `docs/protocol-schema.json` with `chat_command`, + `command_reply`, `plugin_broadcast` so the codegen gate covers them~~ + **RESOLVED 2026-08-04 (remediation pass, this branch)** — schema at 27/39, + constants regenerated both sides, hand-rolled declarations replaced, and + the contract test's exception list is empty now. +- **DC-02** ~~The three open security findings A-2026-08-01/02/03~~ + **RESOLVED 2026-08-04 (remediation pass)** — all three fixed with pinning + tests; statuses closed in [audit-2026-08-04.md](audit-2026-08-04.md). +- **DC-03** ~~Three native e2e specs matched by **no** Playwright project~~ + **RESOLVED 2026-08-04 (remediation pass)** — all three joined + `native-authenticated` (they use the persistent fixture + `ensureLoggedIn`). +- **DC-04** E2E-coverage headline gaps: cert-TOFU flow, E2EE verification, + admin panel, updater (matrix rows 5/38/48/49). **PARTIALLY RESOLVED + 2026-08-04 (remediation pass)** — the cert-TOFU ceremony now has six e2e + tests (`cert-tofu.spec.ts`: first-use content/trust/cancel/non-stacking, + mismatch rows/disconnect). **FURTHER RESOLVED 2026-08-05 (closure pass)** — + the E2EE-verification journey (`voice-e2ee-verify.spec.ts`, 6 tests: + verified badge with safety number + first-sight pin, legacy unverified, + mismatch block, modal reject/trust, DC-08 fail-closed) and the updater + journey (`updater.spec.ts`, 4 tests: silence, banner/Later, progress → + auto-relaunch, failure/Dismiss) shipped. **Remaining: the admin panel + journey (row 48)** — the only flow still without browser automation. + +**P2 — hygiene with real cost** + +- **DC-05** ~~Dead-module deletion decision~~ **RESOLVED 2026-08-04 + (remediation pass, decision: delete)** — `ServerStrip.ts`, `FileUpload.ts`, + `lib/reconcile.ts`, `public/rnnoise-worklet.ts`, `src/generated/**`, the + orphan `getSounds`/`deleteSound` API methods, `incrementDmMention`, their + test files, and the entire typegen pipeline (CI steps, tauri.conf.json + plugin block, Cargo build-dep) are gone; knip is blocking in CI. The dead + `sounds` table fell in the same pass (migration 029, A-2026-07-13). +- **DC-06** ~~`go test -tags wazero` / `-tags otel` run nowhere + (T-2026-07-25-16) — ~598 lines of tests permanently dark.~~ + **RESOLVED 2026-08-05 (closure pass)** — CI's `server-build-test` (ubuntu + leg) now runs `go test -tags wazero ./plugin/...` and + `-tags otel ./telemetry/...`; both passed locally on their first-ever run + (no latent failures were hiding behind the tags). +- **DC-07** ~~Flip `client-e2e` to blocking after a soak~~ **RESOLVED + 2026-08-05 (§13)** — blocking, on the owner's direction, with the soak + evidence recorded in the job comment. +- **DC-08** ~~`getIdentityPin` fail-open on transient keyring errors + (`identity.ts:106-118`, F3 follow-up 3).~~ **RESOLVED 2026-08-05 (closure + pass)** — `getIdentityPin` returns a three-state lookup + (pinned/unpinned/unavailable, mirroring tofu.rs's Err-vs-Ok(None) split); + `verifyPeerAnnounce` rejects the announce on "unavailable" without any pin + write and surfaces the distinct "unknown" badge state. Pinned by unit + tests (pin present / no pin / store error, the rejection path, the badge) + and an e2e case in `voice-e2ee-verify.spec.ts`. +- **DC-09** ~~stale comments; backup-restore audit row; `handleApplyUpdate` + container TODO~~ **RESOLVED 2026-08-05 — in three passes:** comments + (§11), restore audit row (§12), container-aware update refusal (§13). +- **DC-10** Node version skew: CI pins 20, no `.nvmrc`, this session ran 22. + **RESOLVED 2026-08-05 (remediation follow-up)** — `Client/tauri-client/.nvmrc` + pins 20 to match CI, closing 2026-04-07 #11's remainder. +- **DC-11** ~~Resurfaced 2026-04-07 #8: adopt an explicit npm dependency + pinning/review policy~~ **RESOLVED 2026-08-05 (§13)** — policy written in + `docs/contributing.md`. + +**P3 — polish** + +- **DC-12** ~~UX open gaps already carried in the specs: channel-delete toast, + optimistic reactions, slow-mode countdown, admin action in-flight state, + drag-reorder listener leak.~~ **RESOLVED 2026-08-05 (closure pass)** — see + the closed bullets in §4: toast + optimistic reactions shipped with tests; + slow-mode countdown and the in-flight states were verified already + implemented (stale spec notes flipped; the residual role-change + double-fire fixed); the drag-reorder leak replaced with signal ownership. +- **DC-13** ~~Systematic a11y pass over the modal stack (focus traps, + `aria-modal`, screen-reader labels) — nothing tracks this today.~~ + **RESOLVED 2026-08-05 (closure pass)** — see the closed a11y bullet in §4 + (`lib/a11y.ts`, modalFactory + every hand-rolled modal, tablist, combobox + wiring, roving-tabindex pickers, live regions; +71 unit cases + e2e + smoke). +- **DC-14** `voice_speakers` is documented "Reserved — not currently + emitted"; either emit or drop from the schema at the next protocol rev. + (Remediation-pass decision: kept reserved — same treatment as + `member_leave`; dropping either is a protocol rev, not dead-code cleanup.) +- **DC-15** ~~Anchor-drift hygiene: several UX-spec `file:line` anchors were + 200-700 lines stale within 3 weeks; consider symbol-based references.~~ + **RESOLVED 2026-08-05 (closure pass)** — all 55 remaining anchors across + the six UX specs rewritten as symbol references, each verified against the + code (15 were already pointing at entirely wrong lines and were re-aimed); + zero `file:line` references remain under `docs/architecture/ux/`. + +### Recommended next steps (ordered) + +*(Original list, kept for the record — items 2-6 landed in the same-branch +remediation pass below, except the DC-07 soak decision and the E2EE-journey +half of item 4.)* + +1. Land this branch (docs are the record everything else keys off). +2. DC-03 + DC-07 (two-line CI/config changes, immediate coverage payback). +3. DC-01 schema extension as its own reviewed PR (generated Go+TS churn). +4. DC-04: one Playwright spec each for the TOFU trust journey and the E2EE + badge/mismatch journey (both fully mockable in the web harness — the unit + mocks for `cert-tofu` events and peer verification already exist). +5. DC-05 dead-code deletion PR. +6. The security review's A-2026-08-01/02/03 remediation (separate track, + already specified there). + +--- + +## 11. Remediation addendum (2026-08-04, same branch) + +**Method:** the pass above was read-only about code; this addendum records the +remediation commits that followed on the same branch, executing the gap list. +Every closure is stamped in place in the sections above and in the sibling +audits' closure tables; this section is the narrative summary. + +### What shipped + +| Area | Change | Closes | +|------|--------|--------| +| Security | Hierarchy guard on channel-override DELETE; DM exclusion across the admin channel surface; block check on DM rings — each with pinning tests | A-2026-08-01/02/03 (DC-02) | +| Dead code (client) | `ServerStrip.ts`, `FileUpload.ts`, `lib/reconcile.ts`, `public/rnnoise-worklet.ts`, orphan `getSounds`/`deleteSound` + `SoundResponse`, `incrementDmMention`, their test files; `server-strip.spec.ts` renamed `sidebar-header.spec.ts` to say what it tests | DC-05 | +| Dead code (typegen) | `src/generated/**` and its entire feeding pipeline: CI patch/generate steps, `tauri.conf.json` plugin block, inert `Cargo.toml` build-dep (lockfile shrinks by exactly the typegen subtree) | DC-05 | +| Dead schema | Migration `029_drop_sounds_table.sql`; sqlc model regenerated; schema.md / data-model.md / 07-19 closure table updated in the same commit | A-2026-07-13 | +| Server hygiene | `NewWAFMiddleware` wrapper deleted; `MsgTypeAuth` / `MsgTypeDMChannelClose` constants now used at their call sites; stale comments fixed (`config.go` Postgres claim, `host_ui.go` phantom route, `serve_ready.go` PROTOCOL.md) | DC-09 (partial) | +| Protocol | Plugin command family added to `protocol-schema.json` (27 c2s / 39 s2c), constants regenerated Go+TS, hand-rolled declarations replaced, contract-test exception list emptied, protocol.md tables updated | DC-01 | +| Tests | 3 orphaned native specs wired into `native-authenticated` (+14 tests); `tsconfig.e2e.json` + `typecheck:e2e` + CI step (47 spec files were typechecked nowhere; 1 real error found and fixed); `modalFactory.ts` 57.6% → 100%; cert-TOFU ceremony e2e (6 tests, race-free via exposed listener registry) | DC-03, DC-04 (TOFU half) | +| CI/process | knip blocking (its `\|\| true` was masking a real unused-export finding); `claude.yml` actions SHA-pinned like every other workflow; PR template gains the docs-maintenance checkbox A-2026-07-03 recommended | DC-06's sibling gap, A-2026-07-03 | +| Docs | contributing.md (dead postgres row, missing Make targets, tombstone link, coverage claim, branch flow), docs/security.md (broken link, 48h-vs-7d contradiction → SECURITY.md canonical), audit-2026-04-07 closure table (#6/#7/#10/#11), README (branch flow, Docs Index +6, plugin feature row, de-anchored security row), server-configuration env-var subset note, mcp-introspect line count, types.ts header | §5's misses | + +### Decisions taken (and why) + +- **Plugin host-capability API kept.** `HTTPDo`/`RegisterUI`/`Storage*`/`Emit`/ + `UITabBindings` are unwired but tested scaffolding for the announced + host-function work (`sandbox_wazero.go` gate comment); deleting them would + be de-scoping a roadmap feature, not cleaning dead code. The false comments + around them were fixed instead. +- **`voice_speakers` and `member_leave` kept as reserved protocol entries.** + Both are documented "Reserved — not currently emitted" in protocol.md; + removing them is a protocol rev for the owner to schedule (DC-14). +- **`client-e2e` stays non-blocking (DC-07).** Flipping it is explicitly a + soak-length call for the owner; this pass adds green evidence (full suite + including the new TOFU spec) but does not shortcut the soak. +- **DELETE `.../permissions/{roleId}` on a nonexistent role now answers 404** + (was 204) — the cost of resolving the role for the hierarchy guard, and it + matches the PUT twin. The delete-again idempotency contract on an *existing* + role is unchanged. + +### Verification (this session, remediation HEAD) + +| Suite | Result | +|-------|--------| +| Go `go test -race ./...` | all 14 test packages ok (admin 32.5s, api 126.6s, db 262.3s, service 163.3s, ws 166.2s, …) | +| Go `-tags deadlock` pass | all packages ok (ws 54.1s) | +| Go tag-variant builds (`otel`, `wazero`, `otel,wazero`) | all build clean (plus the default build) | +| `make sqlc-verify` + `make protocol-verify` | both pass | +| Client typecheck + typecheck:e2e + oxlint/eslint + prettier | all pass (two pre-existing oxlint style warnings, non-blocking) | +| knip | exits 0 (blocking in CI now) | +| Client unit/integration (vitest) | 164 files, **4360/4360 passed**; coverage 95.35% stmts / 92.04% branches / 93.89% funcs | +| Playwright web suite | **276/276 passed**, 8.9 min (270 baseline + 6 new TOFU tests) | +| Playwright `@parity` subset | 15/15 passed, 33.4s | + +Environment notes: Linux container, Node 22 (CI pins 20 — DC-10 remains), +Go via `GOTOOLCHAIN=auto`; Rust/Tauri compile not attempted here (webkit2gtk +system deps absent) — `rust-tests`' clippy pass and the `tauri-build` job +cover the (inert) `Cargo.toml` change in CI. golangci-lint and the +windows-latest legs are likewise CI-only. + +### Still open after this pass + +*(Historical — superseded by §12's closure pass, which resolved most of +these; §12's own "still open" list is current.)* + +DC-04 (E2EE-verification, admin-panel and updater journeys), DC-06 +(tag-gated Go tests dark in CI), DC-07 (soak decision), DC-08 +(`getIdentityPin` fail-open), DC-09's backup-restore audit row and +`handleApplyUpdate` TODO, DC-10 (`.nvmrc`), DC-11 (npm pinning policy), +DC-12/13/15 (UX gaps, a11y pass, anchor hygiene), and the 2026-04-07 +carryovers #5 (accepted), #8, #9. + +--- + +## 12. Closure addendum (2026-08-05, follow-up branch) + +**Method:** a second remediation pass off `dev` at `7b6d2b0`, executing the +gap list's remaining P2/P3 items. Every closure is stamped in place in §4 +and §9 above; this section is the narrative summary. As before, every claim +was verified against the code and every suite result below comes from an +actual local run. + +### What shipped + +| Area | Change | Closes | +|------|--------|--------| +| CI | `server-build-test` (ubuntu) now RUNS the tag-gated tests it previously only compiled: `-tags wazero ./plugin/...`, `-tags otel ./telemetry/...` — verified green locally on their first-ever run before wiring | DC-06 / T-2026-07-25-16 | +| Security (client) | `getIdentityPin` fail-open fixed: three-state lookup (pinned/unpinned/**unavailable**) mirroring tofu.rs's Err-vs-first-use split; `verifyPeerAnnounce` fails closed on "unavailable" (no verify, no re-pin) and surfaces a distinct amber "could not check" badge (new `unknown` PeerVerification state) | DC-08 (F3 follow-up 3) | +| Server | `backup_restore` audit row, written synchronously *before* the pre-restore safety copy so it survives inside `pre_restore_*.db`; test opens the safety copy and asserts the row; docs/security.md documents where the row lives instead of the gap | DC-09 (restore half) | +| UX polish | Active-channel-delete toast; optimistic reaction toggle with echo-consumption + correlated rollback; role-change submenu double-fire guard; drag-reorder document-listener leak fixed via per-sidebar AbortSignal ownership; slow-mode countdown and admin in-flight states verified already-shipped (stale spec notes flipped) | DC-12 | +| Accessibility | `lib/a11y.ts` (dialog semantics, focus trap, focus restore, roving tabindex) applied through `modalFactory` and every hand-rolled modal/overlay; SettingsOverlay tablist + tabpanel + roving tabs; QuickSwitcher and composer autocompletes wired as combobox/listbox with `aria-activedescendant`; EmojiPicker/GifPicker as keyboard-operable listboxes; Toast/TypingIndicator polite live regions; Escape mapped to each modal's safe action | DC-13 | +| E2E journeys | `voice-e2ee-verify.spec.ts` (6 tests, real ECDSA/ECDH crypto through the production verification path: verified/unverified/mismatch badges, modal reject/trust, DC-08 fail-closed) and `updater.spec.ts` (4 tests: banner → progress → auto-relaunch, failure, dismissals); harness gained per-test identity-pin config, an IPC invoke log, and a LiveKit WebSocket parking shim | DC-04 (rows 38 + 49) | +| Docs | All 55 `file:line` anchors in the six UX specs rewritten as verified symbol references (15 had already rotted onto wrong code); spec gap callouts flipped for everything above | DC-15, spec hygiene | + +### Verification (this session, closure HEAD) + +| Suite | Result | +|-------|--------| +| Go `go test -race -timeout 20m ./...` | all packages ok (ws 125s) | +| Go tag-gated tests (`-tags wazero ./plugin/...`, `-tags otel ./telemetry/...`) | **PASS** — first-ever runs, no latent failures | +| `make sqlc-verify` + `make protocol-verify` | both pass, no generated drift | +| gofmt + go vet | clean | +| Client typecheck + typecheck:e2e | both pass | +| oxlint + ESLint + Prettier | pass (same two pre-existing oxlint style warnings) | +| knip | exits 0 | +| Client unit/integration (vitest) | 165 files, **4474/4474** (+114 over the remediation HEAD) | +| Playwright web suite | see `tests/e2e/E2E-ISSUES.md` for the recorded full-suite run at this HEAD (291 tests: 276 baseline + 6 E2EE + 4 updater + 5 a11y smoke) | + +### Still open after this pass + +*(Historical — superseded by §13's final closure below.)* + +DC-04's admin-panel journey (row 48 — the last flow with no browser +automation), DC-07 (soak decision, owner's call), DC-09's +`handleApplyUpdate` container TODO, DC-11 (npm pinning policy), DC-14 +(reserved protocol entries, owner's call), and the 2026-04-07 carryovers +#5 (accepted), #8, #9. + +--- + +## 13. Final closure (2026-08-05, owner-directed) + +The owner directed the remaining deferrals be executed ("do the remaining +now"), converting the two owner's-call items into decisions: + +- **DC-07 RESOLVED** — `client-e2e` is **blocking**. Soak evidence: green + full-suite runs at 270/276/291 tests across the audit branches; the one + hard CI failure in the window was a real spec bug a non-blocking job + would have hidden. +- **DC-09 RESOLVED (fully)** — the `handleApplyUpdate` container TODO is + code now: `updater.RunningInContainer` (env-authoritative, marker-file + fallback) refuses `POST /admin/api/updates/apply` with 503 + `CONTAINER_DEPLOYMENT`, `GET /updates` gains `can_apply`, the SPA shows + an image-upgrade note instead of the button, and the shipped Dockerfile + sets `OWNCORD_CONTAINER=1`. The backup-restore audit row landed in §12. +- **DC-11 RESOLVED** — the dependency pinning/review policy is written + down in `docs/contributing.md` (lockfiles authoritative, `npm ci`-only + installs, weekly Dependabot with deliberate majors, per-PR security + gates). Also closes 2026-04-07 #8. +- **DC-04 RESOLVED (fully)** — the admin panel has a real-server e2e + journey: `tests/e2e/admin/` boots the Go server fresh and drives the + embedded SPA through the first-run wizard, dashboard stats, channel + create/rename, audit-log verification and sign-out/sign-in, with a + non-blocking `admin-e2e` CI job on the same graduation convention + `client-e2e` followed. Every matrix row now has automation. + +Still open, all deliberate: DC-14 (reserved protocol entries — a protocol +rev, not cleanup), the `admin-e2e` soak graduation, and the 2026-04-07 +carryovers #5 (accepted residual risk) and #9 (service-layer +consolidation, tracked by A-2026-07-10/11's backlog). + +--- + +## 10. Appendix — session command log index + +All suite output was tee'd to the session scratchpad +(`go-test-race.log`, `go-test-deadlock.log`, `go-static.log`, +`client-fast-checks.log`, `eslint.log`, `knip-audit.log`, `vitest-unit.log`, +`client-build.log`, `cargo-test.log`, `cargo-clippy.log`, `e2e-full.log`, +`e2e-parity.log`, `vitest-browser.log`, `npm-ci.log`). The numbers in §3 are +transcribed from those logs; the logs are ephemeral to the audit container +and not committed. + +**Environment accommodations disclosed:** Playwright browsers were served by +symlinking the preinstalled Chromium build 1194 into the directory layout the +repo's Playwright 1.62.1 expects (revision 1234, both `chrome-linux64` and +`chrome-headless-shell-linux64` layouts); the browser-mode vitest run used +`xvfb-run` (headed browser, no X server in the container); ESLint required +CI's own generated-file patch (`ci.yml:110-127`), applied and fully reverted +(`git status` clean afterwards). None of these touch the repository. diff --git a/docs/audit-2026-08-04.md b/docs/audit-2026-08-04.md new file mode 100644 index 00000000..7c50d0e9 --- /dev/null +++ b/docs/audit-2026-08-04.md @@ -0,0 +1,335 @@ +# OwnCord — Security Review + +**Date:** 2026-08-04 +**Branch:** `claude/security-review-workflows-oa0lm5` (audited tree: `cbc4f9e` = `dev`) +**Scope:** whole-codebase security review — Go server, admin panel, WASM plugin host, +LiveKit voice, Tauri desktop client. Read-only; no code changes ship with this audit. +**Relationship to prior audits:** successor to +[audit-2026-07-19.md](audit-2026-07-19.md), which remains the closure tracker for its +own findings. This review is security-only and does not re-open architectural items. + +> **Note on scope selection:** this branch carries no diff against `dev`, so a +> pending-changes review had nothing to examine. The review was run against the full +> codebase instead. + +--- + +## Finding closure status (maintained; update statuses in place) + +| ID | Sev | Finding | Status | +|----|-----|---------|--------| +| A-2026-08-01 | HIGH | `handleDeleteChannelPermission` omits the role-hierarchy and grantability guards its `PUT` twin carries — a MANAGE_CHANNELS holder can clear their own role's channel `deny` and read private channels | RESOLVED 2026-08-04 — DELETE now carries the PUT twin's guards verbatim (role resolved with 404 on missing, fail-closed 401 without an actor role, 403 at/above own position). Pinned by `TestDeleteChannelPermission_RefusesEqualOrHigherRole` + `_UnknownRole` | +| A-2026-08-02 | HIGH | Admin channel `LIST`/`PATCH`/`DELETE` handlers omit the `type == "dm"` guard their sibling `getPermChannel` carries — a MANAGE_CHANNELS holder can enumerate and irreversibly destroy arbitrary DMs and group DMs | RESOLVED 2026-08-04 — list filters `type="dm"` rows; PATCH/DELETE resolve through the new `getAdminChannel`, which answers 404 for DM ids so the surface does not confirm which ids are private conversations. Pinned by `TestListChannels_ExcludesDMs`, `TestPatchChannel_RefusesDM`, `TestDeleteChannel_RefusesDM` | +| A-2026-08-03 | MEDIUM | `DMService.RingTargets` omits the block check every other DM interaction sink performs — a blocked user can ring the person who blocked them | RESOLVED 2026-08-04 — rings route through `requireDMNotBlocked` like every other sink (group DMs stay exempt inside it, matching the send path). Pinned by `TestCallRing_BlockedOneToOneForbidden` + `TestCallRing_GroupWithInternalBlockStillRings` | + +All three share one root cause: **a security predicate applied at some members of a +handler family but not all of them.** The codebase states this rule in its own comments +(`handlers_channel_perms.go:348`: *"clearing a higher-ranked member's override is the +same authority as writing one, so gate it identically"*; `message_perms.go:95`: +*"the single block-check implementation, called from every DM interaction sink"*) and +then violates it in three places. See [§4](#4-systemic-observation) for the structural fix. + +--- + +## 1. A-2026-08-01 — Missing hierarchy guard on channel role-override delete + +* **Severity:** HIGH +* **Category:** `authz_bypass` / privilege escalation +* **Location:** `Server/admin/handlers_channel_perms.go:180` (`handleDeleteChannelPermission`) +* **Route:** `DELETE /admin/api/channels/{id}/permissions/{roleId}` +* **Attacker:** any authenticated user holding a role with `MANAGE_CHANNELS` and *not* + `ADMINISTRATOR` — the seeded **Moderator** role (`0x000FFFFF`, position 60) qualifies. + +### Description + +The role layer of channel permissions is exposed as a `PUT`/`DELETE` pair, both gated +only by `r.Use(requirePerm(permissions.ManageChannels))` (`Server/admin/api.go:70`). + +`handlePutChannelPermission` carries two deliberate escalation guards +(`handlers_channel_perms.go:141-150`): + +```go +// Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR +// cannot grant bits their own role lacks via a channel override. +if err := requireGrantableOverride(actorRole, allow, deny); err != nil { ... } +// Hierarchy guard: a role override can only target a role strictly +// below the actor's own position, mirroring service.requireBelowActor. +if role.Position >= actorRole.Position { ... } +``` + +`handleDeleteChannelPermission` has **neither**. Its whole body resolves the channel, +parses `roleId`, and calls `database.DeleteChannelOverride`. It never reads +`actorRoleFromContext(r)` at all, so no position comparison is possible. + +Deleting an override *is* a permission mutation. `EffectiveChannelPerms` +(`Server/permissions/permissions.go:161`) resolves to `(base &^ deny) | allow`, so +removing the row reverts the role to its bare mask. A private channel in OwnCord is +built precisely by writing a `deny` of `READ_MESSAGES` for the roles that must not see +it — so deleting your own role's row restores exactly the access the `PUT` path refuses +to grant. + +The per-user sibling `handleDeleteChannelUserPermission` +(`handlers_channel_perms.go:333-352`) *does* guard, with a comment stating the rule the +role-layer delete breaks. There is also a dedicated regression test for the `PUT` case +(`TestPutChannelPermission_RefusesEqualOrHigherRole`) and none for `DELETE`. + +### Exploit scenario + +1. Owner creates private `#staff-only` and locks moderators out: + `PUT /admin/api/channels/42/permissions/3` with `{"allow":0,"deny":2}` + (`2` = `READ_MESSAGES`). The channel correctly disappears from the moderator's + `ready` payload, `ListVisibleChannels`, REST reads, and reconnect replay. +2. Moderator confirms the override exists: `GET /admin/api/channels/42/permissions`. +3. Moderator tries the sanctioned path and is refused: + `PUT .../permissions/3` `{"allow":2,"deny":0}` → `403 FORBIDDEN`, + *"cannot manage a role at or above your own rank"* (position 60 ≥ 60). +4. Moderator sends **`DELETE /admin/api/channels/42/permissions/3`** with the same + token. No guard runs. The row is deleted, `permInvalidator.InvalidateAll()` drops + every cached verdict, and `hub.RefreshChannelVisibility(ch)` pushes a live + `channel_create` to the attacker's socket. +5. Effective mask is now the bare `0x000FFFFF`, which includes `READ_MESSAGES`. Full + history, pins, attachments and search on the private channel are readable — plus + `SEND_MESSAGES`, `MANAGE_MESSAGES` and bulk purge, all of which the deny withheld. + +The same request with `roleId=1` or `2` strips an override protecting Owner or Admin — +the exact cross-rank mutation the hierarchy rule exists to forbid. + +### Recommendation + +Give the handler the guards its twin has: load `actorRole := actorRoleFromContext(r)` +(fail closed on `nil`), fetch the target role, and refuse `403` when +`role.Position >= actorRole.Position` unless `permissions.HasAdmin(actorRole.Permissions)`. +Optionally also run `requireGrantableOverride` against the bits the row being removed +carries. Add the `DELETE` twin of `TestPutChannelPermission_RefusesEqualOrHigherRole`. + +--- + +## 2. A-2026-08-02 — Admin channel handlers operate on DM channels + +* **Severity:** HIGH +* **Category:** `missing_authorization_guard` — metadata disclosure + irreversible destruction +* **Location:** `Server/admin/handlers_channels.go:237` (`handleDeleteChannel`); + same defect at `:159` (`handlePatchChannel`) and `:38` (`handleListChannels`) +* **Attacker:** same as A-2026-08-01 — a `MANAGE_CHANNELS` holder who is not an administrator. + +### Description + +DMs and group DMs are ordinary rows in the `channels` table with `type = 'dm'`, sharing +the autoincrement id space with guild channels (`migrations/009_dm_tables.sql`, +`migrations/013_channel_type_constraint.sql`). + +The override handlers in the same package resolve channels through `getPermChannel`, +which explicitly refuses DMs (`handlers_channel_perms.go:40`): + +```go +if ch.Type == "dm" { + writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides") + return nil +} +``` + +That guard is proof the authors knew DM ids reach this route family. Its siblings do not +have it: `handlePatchChannel` and `handleDeleteChannel` both call a bare +`database.GetChannel(r.Context(), id)` and inspect `ch.Type` nowhere. +`handleListChannels` returns `db.ListChannels` verbatim, whose SQL is +`SELECT ... FROM channels ORDER BY position ASC, id ASC` — no `type` predicate — so it +enumerates every private conversation on the server. + +The shipped admin UI already renders these rows: `Server/admin/static/index.html:905` +suppresses only the *lock* button for `type==='dm'`, leaving **Edit** and **Delete** +live. `AdminDeleteChannel` is `DELETE FROM channels WHERE id = ?` with `foreign_keys` +enabled (`db/db.go:59`), and `messages`, `dm_participants` and `dm_open_state` all +declare `ON DELETE CASCADE` — destruction is total and irreversible. + +### Exploit scenario + +1. Moderator authenticates to the admin panel; `adminAuthMiddleware` admits them because + `AdminPerimeter` (`permissions.go:42`) includes `ManageChannels` on its own. +2. `GET /admin/api/channels` returns every DM and group-DM row — ids, plus user-chosen + group names, which act as a membership-graph oracle for conversations they are not + party to, including the owner's. +3. `DELETE /admin/api/channels/{dm_id}` → no `ch.Type` check → cascade wipes the entire + conversation: every message, every participant row, every open-state row. +4. Iterating step 2's ids destroys every private conversation on the server, including + those of principals who strictly outrank the attacker. No hierarchy check, no + participant check, no recovery short of a database restore. +5. `PATCH /admin/api/channels/{dm_id}` rewrites a group DM's `name` and `archived` flag, + silently relabelling the conversation for its real participants. + +### Scope correction + +`PATCH` cannot expose DM message **content**: `AdminUpdateChannel` never writes `type`, +and every DM read path is independently participant-gated +(`permissions/checker.go:113`, `service/message_query.go:25`, `service/channel.go:128`; +`ws/hub_broadcast.go:340` skips `type=="dm"`). The real impact is **metadata +enumeration, irreversible destruction, and silent renaming** — integrity and +availability plus metadata confidentiality, not message-content disclosure. + +### Recommendation + +Route `handleListChannels`, `handlePatchChannel` and `handleDeleteChannel` through the +same DM-refusing resolver `getPermChannel` already implements, returning `404 NOT_FOUND` +so a DM's existence is not confirmed. Filter `type != 'dm'` out of the admin channel +listing — DM lifecycle already has its own participant-gated surface in +`service.DMService`. Add DM-rejection coverage to `admin/handlers_channels_test.go`, +which currently has none. + +--- + +## 3. A-2026-08-03 — `call_ring` / `call_decline` bypass DM block enforcement + +* **Severity:** MEDIUM +* **Category:** `access-control` — user-safety control bypass +* **Location:** `Server/service/dm.go:336` (`DMService.RingTargets`), reached from + `Server/ws/handlers_call.go:45` (`call_ring`) and `:73` (`call_decline`) +* **Attacker:** any ordinary authenticated user who shares an existing 1:1 DM with the victim. + +### Description + +`requireDMNotBlocked` (`Server/service/message_perms.go:117`) describes itself as +*"the single block-check implementation, called from every DM interaction sink — send, +edit, react, pin and typing"*, and its doc comment explains precisely why partial +coverage fails: + +> Enforcing it on the send path alone left a blocked user an open channel to the +> blocker: editing an already-sent message fans `MessageEditedDMEvent` out to every +> participant, so arbitrary new text still reached the person who blocked them, and +> reactions and typing indicators did the same. + +Five sinks call it (`message_perms.go:79`, `message_crud.go:223`, +`message_reactions.go:113`, `channel.go:136`, `message_query.go:212`). `RingTargets` +does not — it checks `IsDMParticipant` and returns the other participants. Blocking does +not remove `dm_participants` rows (`service/block.go` only inserts a block row), so a +blocked user remains a participant, and `CreateDM` only gates *new* DMs — the normal +case is a pre-existing conversation. + +The resulting `CallSignalEvent` is delivered straight to the target's live socket via +`SendToUserHigh` (`ws/emit.go:34`), with no block filtering at the hub. The client +surfaces it as a banner naming the sender plus a repeating chime for 30 s +(`Client/tauri-client/src/lib/call-ring.ts`). `call_ring` is limited to one per 3 s; +`call_decline` has no limiter at all. + +### Caveat — this sits on a documented design boundary + +`Server/ws/deps.go:190` states that blocking is *deliberately* not consulted on the +voice-**access** path (*"it is the message paths' rule … a blocked user is still a +participant"*). That comment governs `hasChannelAccess`, not the ring fan-out, and +`requireDMNotBlocked`'s own sink list does not name ringing. So the maintainers should +decide whether a ring is a message-path sink or a voice-path one. The argument for +treating it as a message-path sink is that it is functionally identical to the typing +indicator the project already hardened: an unsolicited, identity-bearing event pushed to +the blocker's client. Group DMs are correctly out of scope — blocks there are enforced +at `CreateGroupDM` by design. + +### Recommendation + +Call `requireDMNotBlocked` inside `RingTargets` alongside the existing `IsDMParticipant` +check. One call site covers both handlers and matches the five sibling sinks. + +--- + +## 4. Systemic observation + +All three findings are the same defect class: **one member of a handler family enforces +a security predicate and a sibling does not.** In every case the guard already exists, +correct, a few dozen lines away, and in two of the three the codebase's own comments +state the rule being broken. + +This is the same shape as audit-2026-07-19's A-2026-07-07 and A-2026-07-16 (a +channel-visibility rule copy-pasted across five sites, and a server-permission rule +hand-rolled at two), both closed by collapsing the duplicates onto one shared predicate. +That remedy was applied to the *read* paths; these three are *write* and *notify* paths +that were not part of that sweep. + +Suggested follow-up, in preference order: + +1. **Make the resolver own the guard.** Handlers should not receive a `*db.Channel` they + are trusted to validate. One `resolveManageableChannel(r)` helper that refuses DMs and + enforces hierarchy, used by every handler in the admin channel family, makes the + asymmetry impossible rather than merely fixed. +2. **Pair-test the mutation surface.** Every guard test that asserts a refusal on one + verb should have a twin asserting the same refusal on the inverse verb. Both HIGH + findings would have been caught by that rule alone. +3. **Audit the remaining families** for the same shape — the `PUT`/`DELETE`, + `add`/`remove` and `REST`/`WS` pairs elsewhere in `admin/` and `ws/`. + +--- + +## 5. Additional observation — not a vulnerability + +**`Server/admin/handlers_backup.go:177` — restore writes to a hardcoded database path.** + +```go +dbPath := filepath.Join("data", "chatserver.db") +``` + +`main.go:129` opens the live database at `cfg.Database.Path`, which is operator-settable +via `database.path` and `OWNCORD_DATABASE_PATH`. The restore handler ignores both. The +same file deliberately resolves `backupBaseDir` with `filepath.Abs` at init *"so handlers +don't depend on the process CWD (L14)"* — the database path never got the same treatment. + +Because the shipped default *is* `data/chatserver.db`, this is latent: it only bites an +operator who changed the path or runs the server from a different working directory. +When it does bite, `POST /admin/api/backups/{name}/restore` copies the backup over a +decoy file, returns `200 "database restored — server restarting"`, and respawns against +the untouched original — a **silent no-op in the disaster-recovery path**. In an +incident-response context (rolling back to a known-good snapshot after a compromise) the +operator is told they have rolled back and has not. + +A secondary failure sits on the same path: if `copyFile` fails, the handler returns `500` +*without* calling `requestRestart`, but the database was already `Close()`d — so the +process keeps serving every request against a closed database. + +This is listed as an observation rather than a finding because no attacker controls it +and it requires a privileged operator action; it is a correctness bug with security +consequences, not an exploitable vulnerability. Root cause is structural: +`admin.NewAdminAPI` is never passed a `*config.Config` and `db.DB` exposes no path +accessor, so the admin package cannot learn the real path. +`admin/handlers_backup_test.go:336` `chdir`s to a temp dir and hand-creates +`data/chatserver.db`, encoding the hardcoded assumption instead of contrasting it with a +configured path. + +--- + +## 6. Coverage and method + +Two review rounds ran, deliberately along different axes so the second could catch what +the first's shape would miss. + +**Round 1 — by subsystem (12 hunts):** authentication/session/TOTP, authorization, +injection, path/file handling, plugin sandbox, cryptography and the update channel, +admin panel and setup, WebSocket protocol, SSRF, uploads and media, client rendering and +IPC, data exposure. One finding (A-2026-08-01). + +**Round 2 — by cross-cutting modality (6 hunts):** guard asymmetry between sibling code +paths, fail-open error branches in security decisions, sink-driven grep of the whole Go +tree, the unauthenticated surface, cross-user data boundaries traced up from the query +layer, and recently changed code. Two findings (A-2026-08-02, A-2026-08-03) — both of +which the subsystem-shaped round missed, which is the argument for running the second axis. + +Every candidate was put through two independent adversarial reviewers (one instructed to +refute, one applying an exclusion policy) and, on surviving both, a final adjudicator +that re-traced the path from source. Findings below confidence 8/10 were dropped. + +**Rejected during verification** (recorded so they are not re-raised): + +| Candidate | Why rejected | +|-----------|--------------| +| `ws_proxy.rs` TOFU pins self-approvable via `accept_cert_fingerprint` | Only attacker is one who already has arbitrary JS in the webview, who can already call `load_identity_key` — strictly stronger. No marginal gain. | +| `ptt.rs` `ptt_set_key` omits the `is_allowed_ptt_capture_vk` allowlist | Same precondition. Impact is a lossy one-key-at-a-time oracle, not keystroke recovery; the actual keylogging primitive (`ptt_listen_for_key`) *is* still allowlisted. Worth a small fix, not a MEDIUM. | + +**Verified clean** (read and found sound, recorded to save future effort): reconnect +replay authorization across both the hot ring buffer and cold `EventStore` tier, +including the empty-`channelIDs` degenerate case; group-DM membership churn +(`LeaveGroupDM` drops `dm_participants` and `dm_open_state` in one serializable tx); +LiveKit grant scoping (`RoomJoin` bound to `channel-<id>`, no `RoomCreate`, no wildcard); +the migration runner and its per-file transactions; `token_cli.go`; plugin +`host_storage.go`; the admin log-stream ticket (32-byte `crypto/rand`, single-use, TTL'd, +re-checks `HasAdmin` per frame); `client_update.go`; `proc_spawner_nix.go`; and the +metrics and diagnostics endpoints. + +**Not examined.** Coverage was not total. No hunt targeted `Server/telemetry/`, +`Server/syncutil/`, `Server/stackutil/`, `Server/logctx/`, `db/dbgen/`, +`ws/topic_rate_limiter.go`, `ws/event_pruner.go`, `Server/scripts/`, or +`tools/mcp-introspect/`. Vulnerability classes not hunted include backup/restore +integrity beyond §5, SQLite-specific dynamic-`IN` construction, and protocol codegen. diff --git a/docs/audit-test-coverage-2026-07-25.md b/docs/audit-test-coverage-2026-07-25.md index f8e01029..5829a253 100644 --- a/docs/audit-test-coverage-2026-07-25.md +++ b/docs/audit-test-coverage-2026-07-25.md @@ -47,7 +47,7 @@ The client and Rust numbers come from `vitest run --coverage` and a per-file cen | 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-21 | HIGH | **The Playwright web e2e suite does not pass — on `main`.** A local run of the 255 web tests fails **229**, all cascading from `navigateToMainPage` in `tests/e2e/helpers.ts:818` never seeing `[data-testid='app-layout']` after login. Reproduced on a clean `70caa6c` worktree (5/5 failures in `banners-toasts.spec.ts` alone), so it predates this PR and is unrelated to it. It went unnoticed precisely because e2e has never run in CI (T-…-06) | **OPEN — newly discovered.** Found only because this pass tried to wire e2e into CI. The `client-e2e` job is `continue-on-error` with `timeout-minutes: 25`, so it surfaces the breakage without gating on it or burning unbounded minutes. Repairing the login helper is a prerequisite for backlog #2 (promoting the job to blocking) | +| T-2026-07-25-21 | HIGH | **The Playwright web e2e suite does not pass — on `main`.** A local run of the 255 web tests fails **229**, all cascading from `navigateToMainPage` in `tests/e2e/helpers.ts:818` never seeing `[data-testid='app-layout']` after login. Reproduced on a clean `70caa6c` worktree (5/5 failures in `banners-toasts.spec.ts` alone), so it predates this PR and is unrelated to it. It went unnoticed precisely because e2e has never run in CI (T-…-06) | **RESOLVED (verified 2026-08-04).** The mock repair (a `start_http_proxy` stub plus the voice-premise rewrite, noted in `ci.yml`'s `client-e2e` job comment) restored the suite: `client-e2e` runs the full web suite on every PR (still `continue-on-error` pending a flakiness soak) and the blocking `client-e2e-parity` job gates the `@parity` subset. Re-verified by a local run at `5630aa1`: **270/270 passed** (8.6 min, 1 worker, `CI=1`) | | 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 | --- @@ -137,7 +137,7 @@ immediately while no drag is active — so the test pins the real behaviour unde | 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`) — expect RED until T-…-21 is fixed | +| Playwright e2e | **never** | **every PR, non-blocking** (`client-e2e`) — green since the T-…-21 mock repair (270/270 locally at `5630aa1`); the `@parity` subset gates as blocking `client-e2e-parity` | | `-tags wazero` / `-tags otel` tests | never | **still never** (T-…-16) | | Coverage floor / ratchet (Go, Rust) | none | none (T-…-19) | diff --git a/docs/contributing.md b/docs/contributing.md index 1400aef3..3dc7e6a5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -27,7 +27,6 @@ How to set up the development environment and contribute to OwnCord. | `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) | | `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) | | `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) | -| `go build -tags postgres .` | Build with PostgreSQL backend (requires pgx in go.mod) | | `go test ./...` | Run all server tests | | `go test ./... -cover` | Run server tests with coverage | | `go test -race ./...` | Run server tests with race detection | @@ -36,9 +35,15 @@ How to set up the development environment and contribute to OwnCord. | Command | Description | |---------|-------------| +| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) | +| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) | +| `make cover` | Per-package coverage (what CI uploads) + a function summary | +| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) | | `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` | -| `make sqlc-generate` | Regenerate type-safe Go for both SQLite (`db/dbgen/`) and PostgreSQL (`db/pgdbgen/`) engines | -| `make sqlc-verify` | Fail if committed `dbgen` / `pgdbgen` output is stale (used by CI) | +| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) | +| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) | +| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `docs/protocol-schema.json` | +| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) | | `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development | | `make otel-down` | Stop and remove the OTel dev containers | @@ -144,18 +149,47 @@ ci: add lint step to GitHub Actions ## Pull Request Process -1. Branch from `main` -2. PRs target `main`; releases are cut from tagged commits on `main` +1. Branch from `dev` (the active development branch) +2. PRs target `dev`; `dev` is merged to `main` for releases, which are cut from tagged commits on `main` 3. CI must pass (build + test + lint) 4. Request code review 5. Squash merge preferred ## Testing -Target **80%+ coverage**. Follow test-driven development workflow. +The client suite enforces **70% coverage thresholds** in `vitest.config.ts`; +the Go suite has deliberately no floor (T-2026-07-25-19) — use `make cover-all` +to see the honest cross-package number. Follow a test-driven workflow and never +lower a threshold to make a change fit. ## Code Style -- **TypeScript**: See [Client Architecture](client-architecture.md) +- **TypeScript**: See [Client Architecture](architecture/client.md) - **Go**: `gofmt` + `golangci-lint`, standard library preferred - **Rust**: `cargo fmt` + `cargo clippy`, minimal code (native APIs only) + +## Dependency Policy + +The policy behind what the lockfiles already enforce (decided 2026-08-05, +closing audit findings 2026-04-07 #8 / DC-11): + +- **Lockfiles are authoritative.** `package-lock.json`, `go.sum` and + `Cargo.lock` pin every transitive dependency; CI installs only from them + (`npm ci`, module/registry verification — never a bare `npm install` in CI + or hooks). `package.json` keeps ordinary caret ranges: exact-pinning it + would duplicate what the lockfile does while making every security patch a + manual edit. +- **Upgrades arrive as reviewed PRs, not ambient drift.** Dependabot runs + weekly per ecosystem (`.github/dependabot.yml`) with semver-major updates + ignored across the board — majors are adopted deliberately, by a human, + reading the changelog. Peer-coupled groups (`vitest`/`@vitest/*`, + `@stryker-mutator/*`) update as one PR so exact peer pins cannot wedge. +- **Security gates run on every PR:** `npm audit --omit=dev + --audit-level=high` (shipped deps only — dev-tooling advisories are + triaged in the workflow comment instead of blocking on unfixable pins), + `govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused + client dependencies outright. +- **Version skew is pinned at the toolchain level** too: `.nvmrc` + CI both + say Node 20, `Server/sqlc.version` pins sqlc, Go pins via `go.mod` + (`GOTOOLCHAIN=auto`), and GitHub Actions are SHA-pinned with Dependabot + bumping the pins. diff --git a/docs/credential-storage.md b/docs/credential-storage.md index 2aa41c7f..cad6ea56 100644 --- a/docs/credential-storage.md +++ b/docs/credential-storage.md @@ -103,7 +103,8 @@ served it, touching no real credential: ```js await invoke("probe_credential_store") -// { ok: true, backend: "keyring", error: null } +// { ok: true, backend: "Keyring", error: null } +// (Backend enum variants serialize verbatim: "Keyring" | "DpapiFile" | "EncryptedFile") ``` ### From Windows directly diff --git a/docs/deployment.md b/docs/deployment.md index 8a60186d..093cc28f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,13 +16,13 @@ Production deployment guide for OwnCord server on Windows and Linux. **Windows:** ```bash cd Server -go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.1" . ``` **Linux:** ```bash cd Server -CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . +CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.1" . ``` - `-s -w` strips debug info (smaller binary) @@ -35,7 +35,7 @@ Alternatively, download a pre-built binary from GitHub Releases: ## Docker (Linux) -The easiest way to run OwnCord on Linux. Includes the chat server and LiveKit voice/video as separate containers on a shared internal network. +The easiest way to run OwnCord on Linux. Includes the chat server and LiveKit voice/video as separate containers on a shared internal network. The server image is built `FROM gcr.io/distroless/static-debian12` and runs as a non-root user (`65532`), so there is no shell inside the container. ### Prerequisites @@ -96,6 +96,14 @@ docker compose up -d The named volume is preserved — no data loss. +Pulling the image is the **only** upgrade path in Docker: the admin panel's +in-place "Apply Update & Restart" is refused in container deployments (503 +`CONTAINER_DEPLOYMENT`), because the running binary is image content — a +replacement written next to it would die with the container. The shipped +image sets `OWNCORD_CONTAINER=1` to mark this; operators who bind-mount the +server binary into a container and genuinely want in-place self-update can +set `OWNCORD_CONTAINER=0` to opt back in. + ### LiveKit in Docker LiveKit runs as its own container (`livekit/livekit-server:v1`) and is **not** managed by OwnCord's companion-process system. Leave `voice.livekit_binary` unset. See [LiveKit Setup — Docker](livekit-setup.md#docker) for details. @@ -242,12 +250,14 @@ Restoring replaces the live database file. A pre-restore safety backup is create ```json { "status": "ok", - "version": "1.0.0", "uptime": 86400, "online_users": 12 } ``` +The server version is deliberately not exposed on this unauthenticated +endpoint (anti-fingerprinting hardening). + ### Metrics Endpoint `GET /api/v1/metrics` -- admin IP restricted. @@ -262,6 +272,7 @@ Restoring replaces the live database file. A pre-restore safety backup is create "num_gc": 150, "connected_users": 12, "voice_sessions": 3, + "broadcast_drops": 0, "livekit_healthy": true } ``` diff --git a/docs/mcp-introspect.md b/docs/mcp-introspect.md index 681636f1..5723399e 100644 --- a/docs/mcp-introspect.md +++ b/docs/mcp-introspect.md @@ -8,7 +8,7 @@ It is a **development tool**, not part of the shipped product. It ships no data adds nothing to the server binary — it is a thin wrapper over OwnCord's existing REST API plus the client's on-disk log. -- **Code:** `tools/mcp-introspect/index.mjs` (one file, ~230 lines) +- **Code:** `tools/mcp-introspect/index.mjs` (one file, ~270 lines) - **Runtime:** Node ≥ 20, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`) - **Registration:** `/.mcp.json` (committed) and `.claude/settings.local.json` (local) diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 5266cb9e..de82bb77 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -1,5 +1,9 @@ # Audit 2026-07-19 — Maintainer Decisions +> **Status (verified 2026-08-04): Shipped (decision record).** All 13 +> decisions carry an Implemented status below; spot-verified against the code +> (D2 `db/dbgen` wiring, D9 `VisibleChannelIDs`, D13 `HasServerPerm`). + **Date decided:** 2026-07-19 (D1–D8); 2026-07-20 (D9–D12); 2026-07-21 (D13) **Decided by:** J3vb **Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status. **2026-07-20:** backlog items 3 and 11 (D9, D10) implemented — channel-visibility unified through `permissions.Checker`; V2 dispatch migration finished and V1 deleted. **2026-07-20 (P3):** the five plugin CRITICALs carried over from audit-2026-04-07 dispositioned (D11) — four closed, one accepted as residual risk, which keeps plugins default-disabled at the beta gate. **2026-07-23:** D13 implemented — server-scoped permission rule unified in `permissions.HasServerPerm`; override-fetch errors fail closed instead of dropping (and caching the loss of) every channel `deny`; the fifth D9 site routed through the `Checker`. diff --git a/docs/plans/channel-visibility-unification.md b/docs/plans/channel-visibility-unification.md index 3780eaa9..1c24d092 100644 --- a/docs/plans/channel-visibility-unification.md +++ b/docs/plans/channel-visibility-unification.md @@ -1,6 +1,8 @@ # Channel-Visibility Unification (backlog item 3) — Design -**Status:** implemented 2026-07-20 (D9) +**Status:** implemented 2026-07-20 (D9) — re-verified 2026-08-04 +(`permissions/checker.go:110` `VisibleChannelIDs`; four delegating call sites; +REST/WS agreement test `Server/ws/channel_visibility_agreement_test.go`) **Decision:** D9 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) **Closes:** audit finding A-2026-07-07 (backlog §6 item 3) diff --git a/docs/plans/discord-parity.md b/docs/plans/discord-parity.md index 16e7d71b..6c989986 100644 --- a/docs/plans/discord-parity.md +++ b/docs/plans/discord-parity.md @@ -1,5 +1,18 @@ # Discord feature parity — gap analysis and plan +> **Status (verified 2026-08-04): Shipped — phases 1–6 complete.** Phase 1's +> table below was written as a gap list and never re-marked; all six rows have +> since shipped (block/unblock UI `SidebarMemberSection.ts:177-186`; topics in +> `ready` via `channelPayloadFrom`; role colors from the server list with +> seeded-name fallback `formatting.ts:158-175`; profile popup mounted from +> `MemberList.ts`; temp bans `BanDurationHours` in `PATCH /admin/api/users/{id}`; +> archived channels hidden by the unified predicate +> `permissions/checker.go:116-121`). Named leftovers remain open and are listed +> in-line: role hoist/mentionable flags + `@RoleName` mentions (Phase 5), +> categories as real entities (Phase 5), and the §"still-dead code" cleanup +> list (`sounds` table, `voice_speakers`, `voice_config.bitrate`, macOS PTT +> stub). + Status: phase 6 complete (2026-08-01) This is a depth audit of features OwnCord already has, compared against what diff --git a/docs/plans/http-tofu-proxy.md b/docs/plans/http-tofu-proxy.md index 33c085e0..a9c06e26 100644 --- a/docs/plans/http-tofu-proxy.md +++ b/docs/plans/http-tofu-proxy.md @@ -1,6 +1,8 @@ # Client HTTP TOFU Proxy (D5) — Design -**Status:** implemented 2026-07-19 +**Status:** implemented 2026-07-19 — re-verified 2026-08-04 +(`src-tauri/src/http_proxy.rs` + `src/lib/httpProxy.ts`; capability scope in +`capabilities/default.json`) **Decision:** D5 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) — "next security work" **Closes:** audit finding A-2026-07-02 (client HTTP path accepts any TLS certificate) diff --git a/docs/plans/permission-middleware-consolidation.md b/docs/plans/permission-middleware-consolidation.md index 76ea754c..616b707b 100644 --- a/docs/plans/permission-middleware-consolidation.md +++ b/docs/plans/permission-middleware-consolidation.md @@ -1,6 +1,10 @@ # Permission-Middleware Consolidation (audit finding A-2026-07-16) — Design -**Status:** implemented 2026-07-23 (D13) +**Status:** implemented 2026-07-23 (D13) — re-verified 2026-08-04. The one +deliberately-unfixed copy disclosed at the end of this document, +`ws.channelCanSend`, still exists as a hand-rolled resolution (now at +`Server/ws/serve_ready.go:119`, feeding the ready payload's `can_send` flag) — +the disclosed follow-up remains open. **Decision:** D13 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) **Closes:** audit finding A-2026-07-16 (new, HIGH). Closes **none** of backlog §6 item 12's findings — A-2026-07-06, A-2026-07-10 and A-2026-07-11 all remain diff --git a/docs/plans/security-hardening-remediation.md b/docs/plans/security-hardening-remediation.md index 136f852b..a8f1b40f 100644 --- a/docs/plans/security-hardening-remediation.md +++ b/docs/plans/security-hardening-remediation.md @@ -1,6 +1,6 @@ # Plan: Remediate security-hardening review regressions -**Status:** COMPLETE — verified 2026-07-23 (branch `feat/e2ee-identity-tofu`): every item +**Status:** COMPLETE — verified 2026-07-23, re-confirmed 2026-08-04 (branch `feat/e2ee-identity-tofu`): every item has been implemented or superseded. W2-4 and both halves of W3-3 are the last to land. **W2-4:** DONE 2026-07-23 — `Server/db/attachment_queries.go:111` `LinkAttachmentsToMessage` links atomically and skips (not fails) already-linked, diff --git a/docs/plans/security-scan-2026-07-22-remediation.md b/docs/plans/security-scan-2026-07-22-remediation.md index 9a41ffa8..fb4d858a 100644 --- a/docs/plans/security-scan-2026-07-22-remediation.md +++ b/docs/plans/security-scan-2026-07-22-remediation.md @@ -1,5 +1,16 @@ # Security-Scan Remediation (Claude Security run 2026-07-22) +> **Status (verified 2026-08-04): Shipped — all 8 findings (F1–F8) closed.** +> Of the four F3 follow-ups listed below, two have since shipped: the safety +> number is rendered (voice-roster shield badge title, `ChannelSidebar.ts:45-60`) +> and the re-pin affordance exists (mismatch badge click → identity-mismatch +> modal → `rePinPeerIdentity`, `ChannelSidebar.ts:84-135`). Follow-up 3 +> (`getIdentityPin` fail-open on a transient keyring read error, +> `identity.ts:106-118`) remains open; follow-up 4 is accepted behavior +> (degrades to *unverified*, never wrongly-*verified*). The scan artifact +> directory `CLAUDE-SECURITY-20260722-184557/` referenced below is not part of +> this repository. + **Scan:** `CLAUDE-SECURITY-20260722-184557/` at revision `e983459` (branch `main`). **Findings:** 8 — 4 MEDIUM (F1–F4), 4 LOW (F5–F8), all confidence `medium`, no HIGH. **Branch:** `fix/security-scan-2026-07-22`. diff --git a/docs/plans/slash-commands.md b/docs/plans/slash-commands.md index e1068026..76e34831 100644 --- a/docs/plans/slash-commands.md +++ b/docs/plans/slash-commands.md @@ -1,6 +1,15 @@ # Plan: Slash command dispatcher in WS **Status:** design only, not implemented + +> **Staleness notes (2026-08-04):** this design predates several changes and +> needs a refresh before implementation: migration number `016` is now taken +> (`016_announcement_channel_type.sql` — the plan's `016_plugin_commands.sql` +> must be renumbered, as must its checklist); `Server/store/` no longer exists +> (deleted in D3 — the `sqlite_plugin_commands.go` file below would live in +> `Server/db/` now); `src/state/` does not exist in the client (state modules +> live in `src/stores/`). One slice of this plan did land separately: the +> manifest `commands` name-only ACL (see the inline note in §"Manifest"). **Owner:** TBD **Tracks:** deferred feature backlog (post-beta; see CHANGELOG "Deferred work") **Estimated effort:** 1–2 weeks of focused work diff --git a/docs/plans/sqlc-adoption.md b/docs/plans/sqlc-adoption.md index 0090d9a2..bb66b628 100644 --- a/docs/plans/sqlc-adoption.md +++ b/docs/plans/sqlc-adoption.md @@ -1,5 +1,12 @@ # sqlc Adoption (D2) — Progress & Plan +> **Status (verified 2026-08-04): Shipped.** `db.DB` delegates to the +> sqlc-generated `db/dbgen` layer (`Server/db/db.go`), CI verifies generated +> output (`make sqlc-verify`), and the documented remainder of raw queries +> (variable `IN` lists, FTS, multi-statement transactions, PRAGMA/VACUUM) is +> intentional. The "out of scope: `store/` SQL" note below is moot — the +> `store` package was deleted (D3). + **Decision:** D2 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) — adopt sqlc as the real query layer; **Closes:** audit finding A-2026-07-05 (dead `db/dbgen`). diff --git a/docs/plans/tauri-capability-narrowing.md b/docs/plans/tauri-capability-narrowing.md index 91f92412..00e4fa1f 100644 --- a/docs/plans/tauri-capability-narrowing.md +++ b/docs/plans/tauri-capability-narrowing.md @@ -1,9 +1,11 @@ # Tauri HTTP Capability Narrowing — Design -**Status:** implemented (2026-07-20) — the Decision below landed in -`Client/tauri-client/src-tauri/capabilities/default.json`, guarded by -`tests/unit/capabilities-scope.test.ts`. The follow-up at the end of this -document is still open. +**Status:** implemented (2026-07-20), re-verified 2026-08-04 — the Decision +below landed in `Client/tauri-client/src-tauri/capabilities/default.json`, +guarded by `tests/unit/capabilities-scope.test.ts`. The follow-up at the end +of this document (move the link-preview fetch behind a Rust command that +resolves DNS and rejects private/loopback IPs, closing DNS rebinding and +allowing the `https://*` wildcard to be dropped) is **still open**. **Phase:** P3 "Client + plugin security parity" **Follows:** [http-tofu-proxy.md](http-tofu-proxy.md) (A-2026-07-02), which moved REST/health/attachment traffic onto a loopback origin and was expected to make diff --git a/docs/plans/v2-dispatch-migration.md b/docs/plans/v2-dispatch-migration.md index 6e4ef980..d29e1f95 100644 --- a/docs/plans/v2-dispatch-migration.md +++ b/docs/plans/v2-dispatch-migration.md @@ -1,6 +1,8 @@ # Finish the V2 Dispatch Migration (backlog item 11) — Design -**Status:** implemented 2026-07-20 (D10) +**Status:** implemented 2026-07-20 (D10) — re-verified 2026-08-04 +(`Server/ws/registry.go` holds only `handlersV2`; no V1 registry symbols +remain) **Decision:** D10 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) **Closes:** audit finding A-2026-07-09 (backlog §6 item 11) diff --git a/docs/protocol-schema.json b/docs/protocol-schema.json index abfb5967..f7fcd4b0 100644 --- a/docs/protocol-schema.json +++ b/docs/protocol-schema.json @@ -32,7 +32,13 @@ { "wire": "voice_e2ee_announce", "go": "MsgTypeVoiceE2EEAnnounce", "ts": "VOICE_E2EE_ANNOUNCE" }, { "wire": "voice_e2ee_offer", "go": "MsgTypeVoiceE2EEOffer", "ts": "VOICE_E2EE_OFFER" }, { "wire": "call_ring", "go": "MsgTypeCallRing", "ts": "CALL_RING" }, - { "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" } + { "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" }, + { + "wire": "chat_command", + "go": "MsgTypeChatCommand", + "ts": "CHAT_COMMAND", + "note": "plugin slash-command dispatch (Phase C)" + } ], "server_to_client": [ { "wire": "auth_ok", "go": "MsgTypeAuthOK", "ts": "AUTH_OK" }, @@ -86,6 +92,18 @@ "go": "MsgTypeVoiceE2EEOfferRelay", "ts": "VOICE_E2EE_OFFER", "note": "relay (same string as client msg)" + }, + { + "wire": "command_reply", + "go": "MsgTypeCommandReply", + "ts": "COMMAND_REPLY", + "note": "ephemeral plugin reply, sent only to the invoking client" + }, + { + "wire": "plugin_broadcast", + "go": "MsgTypePluginBroadcast", + "ts": "PLUGIN_BROADCAST", + "note": "plugin channel broadcast, gated by the sender's SEND_MESSAGES" } ] } diff --git a/docs/protocol.md b/docs/protocol.md index d5164a81..897ef1b1 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -93,12 +93,26 @@ The sequence number system enables reconnection with state recovery. | Category | Has seq? | Examples | |----------|----------|---------| | Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` | -| Global broadcasts | Yes | `presence` (except the invisible split, see Presence), `member_join`, `member_leave`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` | -| Ephemeral | No | `typing` | +| Global broadcasts | Yes | `member_join`, `member_leave`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` | +| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) | | DM messages | No | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update`, `dm_channel_open`, `dm_channel_close` | | Call signalling | No | `call_incoming`, `call_declined` | | Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` | +**`presence` is split, and only one half is sequenced.** Connect and disconnect +presence is a normal sequenced global broadcast, so it replays on a warm resume. +A `presence` caused by the user changing their own status (`presence_update`) is +sent on the low-priority, droppable tier instead: it carries no `seq`, it can be +shed under send-buffer pressure for a fully connected client, and it is not +replayed — so a status change made while a client was away is not delivered when +that client resumes. + +This is deliberate, not an oversight: presence is best-effort by design, and +`member_join` carries `status` precisely so a client can re-derive presence +without depending on the correction arriving. Clients must treat presence as +eventually-consistent and must not assume they have seen every transition. See +Presence for the invisible-member split. + --- ## Authentication Flow @@ -121,6 +135,19 @@ After the WebSocket connection is established, the client sends the first messag |-------|------|----------|-------------| | `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` | | `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. | +| `active_channel_id` | int64 | No | The channel the client had open when it disconnected. Honoured only on a resume (`last_seq > 0`) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown. | + +`active_channel_id` closes a resume-only gap. The hub restores a reconnecting +client's channel subscription by copying it from the previous connection entry, +but that entry is deleted as soon as the server observes the old socket close — +which normally happens well before the client reconnects. Without the hint the +resumed socket holds no channel subscription until its post-`auth_ok` +`channel_focus` round trip completes, and everything broadcast to that channel +in the meantime reaches nobody on that connection and can never be re-requested, +since the client only ever reports `max(seq)`. + +Clients should still send `channel_focus` after `auth_ok` — it remains the +fallback for servers that predate this field, and it is idempotent. ### Step 2: Success -- auth_ok @@ -640,6 +667,17 @@ All channel update messages are broadcast to all connected clients. Triggered by } ``` +`can_send` is an **optional extra field on the targeted form only.** When a role +or channel-override edit changes who may post, `RefreshChannelVisibility` sends +each still-visible client its own `channel_create`, and that copy carries this +viewer's `can_send` — the same value `ready` ships per channel — so the composer +affordance converges without a reconnect. + +The broadcast form omits it: one encoded frame is delivered to a whole audience, +and a single value would be wrong for some of them. Older servers omit it too. +**Treat an absent `can_send` as "unchanged", never as `false`** — a client that +resets on absence would disable the composer on every ordinary broadcast. + ### channel_update (Server -> Client, broadcast) Full channel object — the same payload shape as `channel_create`, built by the @@ -658,6 +696,14 @@ Archiving or unarchiving additionally triggers targeted `channel_create` / `channel_delete` sends (`Hub.RefreshChannelVisibility`), because it changes who may see the channel rather than only how it looks. +**An archived channel is read-only, and the server enforces that.** History +stays readable, but `chat_send` is refused with `FORBIDDEN` and `voice_join` +with `BAD_REQUEST`, and archiving a voice channel evicts whoever is already +connected. Hiding the channel is not on its own a protection: a caller that +still holds the id — a custom client, or a stock client racing the +`channel_delete` the archive transition sends — would otherwise keep writing +into an archive that nobody can see or moderate. + ### channel_delete (Server -> Client, broadcast) ```json @@ -1363,11 +1409,18 @@ All rate limits are enforced server-side using a token bucket rate limiter. | Typing | 1 | 3 seconds | Silently dropped | | Presence | 1 | 10 seconds | `RATE_LIMITED` error | | Reactions | 5 | 1 second | `RATE_LIMITED` error | +| Voice join / leave | 5 | 1 second | `RATE_LIMITED` error | | Voice camera | 2 | 1 second | `RATE_LIMITED` error | | Voice screenshare | 2 | 1 second | `RATE_LIMITED` error | | Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error | -| Voice E2EE announce/offer | 5 | 1 second | `RATE_LIMITED` error | +| Voice E2EE announce | 5 | 1 second | `RATE_LIMITED` error | +| Voice E2EE offer | 64 | 1 second | `RATE_LIMITED` error | | Voice moderation (mute/deafen/move/kick) | 5 | 1 second | `RATE_LIMITED` error | +| Call ring | 1 | 3 seconds | `RATE_LIMITED` error | + +The E2EE offer budget is deliberately higher than the announce budget: a key +rotation fires one offer per peer in a single burst, so the limit is sized to +a whole rotation rather than to a single frame. --- @@ -1378,7 +1431,7 @@ from which the Go and TypeScript constant files are generated (`make protocol-generate` / verified in CI by `make protocol-verify`). The tables below add per-type behavioral notes. -### Client -> Server (24 types) +### Client -> Server (27 types) | Type | Rate Limit | Notes | |------|-----------|-------| @@ -1392,8 +1445,8 @@ tables below add per-type behavioral notes. | `channel_focus` | None | Updates read state | | `mark_read` | None | Updates read state without moving focus | | `presence_update` | 1/10sec | | -| `voice_join` | None | | -| `voice_leave` | None | Empty payload | +| `voice_join` | 5/sec | | +| `voice_leave` | 5/sec | Empty payload | | `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted | | `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened | | `voice_camera` | 2/sec | Requires USE_VIDEO | @@ -1404,10 +1457,13 @@ tables below add per-type behavioral notes. | `voice_mod_kick` | 5/sec | Requires MUTE_MEMBERS + outranks target | | `voice_token_refresh` | 1/60sec | Must be in voice | | `voice_e2ee_announce` | 5/sec | ECDH pubkey announce | -| `voice_e2ee_offer` | 5/sec | Wrapped room key to target | +| `voice_e2ee_offer` | 64/sec | Wrapped room key to target (budgeted per key rotation) | +| `call_ring` | 1/3sec | DM participants only; fans out as `call_incoming` | +| `call_decline` | None | DM participants only; fans out as `call_declined` | +| `chat_command` | None | Plugin slash command; max 64 args; broadcast gated by `CanPost` | | `ping` | None | Heartbeat | -### Server -> Client (33 types) +### Server -> Client (39 types) | Type | Has seq? | Delivery | |------|----------|----------| @@ -1438,10 +1494,28 @@ tables below add per-type behavioral notes. | `user_update` | Yes | All clients (profile changes) | | `member_ban` | Yes | All clients | | `roles_update` | Yes | All clients (full role list) | +| `emoji_update` | Yes | All clients (full custom-emoji set) | | `dm_channel_open` | No | Direct to participant | | `dm_channel_close` | No | Direct to participant | +| `call_incoming` | No | Direct to each other DM participant | +| `call_declined` | No | Direct to each other DM participant | | `voice_e2ee_announce` | No | Voice channel (excl. sender) | | `voice_e2ee_offer` | No | Direct to target participant | | `server_restart` | Yes | All clients | | `error` | No | Direct to requester | | `pong` | No | Direct to pinger | +| `command_reply` | No | Direct to invoking client (ephemeral plugin reply) | +| `plugin_broadcast` | No | Channel (plugin output posted as a broadcast) | + +### Plugin command types + +Three wire types exist for the WASM plugin system. Since 2026-08-04 they are +listed in `protocol-schema.json` like every other type (closing DC-01), so +the generated constants cover them and `make protocol-verify` plus the +`ws` package's protocol-contract test gate them against drift. + +| Type | Direction | Notes | +|------|-----------|-------| +| `chat_command` | Client -> Server | `{command, args[], channel_id, req_id?}`; max 64 args; unknown commands return an `error`. No dedicated rate limit; a channel broadcast is gated by the same `CanPost` policy as a real message send. | +| `command_reply` | Server -> Client | Ephemeral plugin reply, sent only to the invoking client; echoes `req_id`. Payload: `{text}`. | +| `plugin_broadcast` | Server -> Client | Plugin output posted to a channel. Payload: `{channel_id, user_id, command, text}`. | diff --git a/docs/quick-start.md b/docs/quick-start.md index e10e8a8e..db4fc681 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -55,11 +55,11 @@ Full Docker details: [Deployment Guide](deployment.md#docker-linux). ```bash # Server (Windows) cd Server -go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.1" . # Server (Linux) cd Server -CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . +CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.1" . # Client cd Client/tauri-client diff --git a/docs/schema.md b/docs/schema.md index be7d2df8..806fb8bc 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS schema_versions ( | `026_emoji_mime.sql` | Adds `emoji.mime_type` — the sniffed image type, so the emoji image route can send a Content-Type without re-reading the file | | `027_user_profile_fields.sql` | Adds `users.display_name`, `users.about`, `users.custom_status`, and a partial index on `users(avatar)` for the file route's avatar-authorization probe | | `028_group_dms.sql` | Adds `channels.is_group` + a partial index — marks a DM channel as a group so group-ness survives people leaving | +| `029_drop_sounds_table.sql` | Drops `sounds` — dead since 001; the soundboard it was created for was never built (A-2026-07-13) | --- @@ -635,25 +636,6 @@ was added), and every mutation broadcasts the whole set as `emoji_update`. --- -### sounds - -**Dead schema.** Created by the initial schema for the soundboard feature, -which has since been removed; the table remains but nothing reads or writes it. -Slated for a cleanup migration (audit A-2026-07-13). - -```sql -CREATE TABLE sounds ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - filename TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - uploaded_by INTEGER NOT NULL REFERENCES users(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - ---- - ### rate_lockouts Persists rate-limiter lockouts (e.g. repeated failed logins) so they survive diff --git a/docs/security.md b/docs/security.md index 275aed58..9df51c2a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,15 +4,14 @@ Security guidelines and vulnerability reporting for OwnCord. ## Reporting Vulnerabilities -Use GitHub Security Advisories to report vulnerabilities: go to Settings > Security > Advisories and create a new advisory. +Report privately via GitHub Security Advisories: +[github.com/J3vb/OwnCord/security/advisories/new](https://github.com/J3vb/OwnCord/security/advisories/new). **Do NOT open public issues for security bugs.** -## Response Timeline - -- **Acknowledgment:** Within 48 hours -- **Critical fixes:** Within 7 days -- **Non-critical fixes:** Included in the next release +The repository-root [SECURITY.md](../SECURITY.md) is the canonical reporting +policy — what to include and the response timeline (initial response within +7 days) live there, so the two files cannot disagree. ## Two-Factor Authentication @@ -33,18 +32,24 @@ Users can delete their own account via `DELETE /api/v1/auth/account` with passwo Security-relevant actions are recorded in the `audit_log` table with actor, action, target, and detail: -- **Auth:** `user_register`, `user_login`, `user_logout`, `login_blocked_banned`, `account_deleted` +- **Auth:** `user_register`, `user_login`, `user_logout`, `login_blocked_banned`, `account_deleted`, `password_change`, `session_revoke` - **2FA:** `totp_enabled`, `totp_verified`, `totp_disabled` -- **Admin:** `role_change`, `user_ban`, `user_unban`, `force_logout`, `setting_change`, `server_setup` -- **Content:** `channel_create`, `channel_update`, `channel_delete`, `message_delete` +- **Admin:** `role_change`, `role_create`, `role_update`, `role_delete`, `role_reorder`, `user_ban`, `user_unban`, `force_logout`, `setting_change`, `server_setup`, `api_token_create`, `api_token_revoke`, `config_write` +- **Content:** `channel_create`, `channel_update`, `channel_delete`, `channel_perms_update`, `channel_perms_clear`, `channel_user_perms_update`, `channel_user_perms_clear`, `message_delete`, `message_purge`, `emoji_create`, `emoji_delete` +- **Profile:** `profile_update`, `identity_key_update` - **Ops:** `backup_create`, `backup_delete`, `backup_restore`, `ws_connect` +Note: `backup_restore` is written synchronously to the live database *before* +the pre-restore safety copy is taken, so the row survives inside the +`pre_restore_*.db` backup. The restored database itself will not contain it — +the restore replaces the database file wholesale. + ## Client Security Hardening The Tauri desktop client implements the following security measures: ### Credential Storage -- Credentials are stored in Windows Credential Manager via DPAPI (per-user scope, `CRED_PERSIST_ENTERPRISE`) +- Credentials are stored in the OS keyring (Windows Credential Manager / macOS Keychain / Secret Service) via the `keyring` crate, with every write read back and verified; if no keyring is available they fall back to an encrypted file (Windows DPAPI with `CRYPTPROTECT_UI_FORBIDDEN`, ChaCha20-Poly1305 elsewhere) — see [credential-storage.md](credential-storage.md) - Plaintext passwords are **never** returned to the frontend over IPC — only tokens are accessible from JavaScript - Auto-login uses stored tokens for reconnection, not passwords @@ -86,8 +91,7 @@ The Tauri desktop client implements the following security measures: ## Known Limitations -- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work -- The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production +- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](../Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work - CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time). Because of this, narrowing the Tauri `http:allow-fetch` scope alone would not bound exfiltration from a compromised renderer — the webview's own `fetch` reaches the same hosts without going through the plugin. Closing that requires narrowing `connect-src` and moving the link-preview fetch into Rust in the same change ## Security Hardening Checklist for Operators @@ -98,6 +102,6 @@ The Tauri desktop client implements the following security measures: - [ ] Configure rate limits (defaults are sensible but review for your use case) - [ ] Run regular backups via the admin panel - [ ] Keep the server updated (admin panel shows available updates) -- [ ] Firewall: only expose port 8443 (HTTPS) and 7880 (LiveKit WebSocket for voice/video) +- [ ] Firewall: only expose port 8443 (HTTPS); for voice/video also 7880-7881/TCP and 50000-60000/UDP (LiveKit signaling + media — see [deployment.md](deployment.md)); port 80 only when using ACME - [ ] Enable server-wide 2FA requirement once all users have enrolled - [ ] Set `admin_allowed_cidrs` to restrict admin panel access to trusted networks diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 8906f8a8..0ef90705 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -36,6 +36,9 @@ the server automatically when a startup-only value changed. Note that | `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins for **web/browser** clients; empty list DENIES all cross-origin (set to `["*"]` to allow any origin). The OwnCord desktop client needs no entry here — its webview origins (`http(s)://tauri.localhost`, `tauri://localhost`) are always accepted. | | `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) | | `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` | +| `server.waf_enabled` | bool | `false` | Enable the Coraza WAF middleware (inline rules + OWASP Core Rule Set) | +| `server.waf_paranoia_level` | int | `2` | OWASP CRS paranoia level 1–4; values outside that range fall back to 2 | +| `server.waf_crs_mode` | string | `"detect"` | CRS layer mode: `off` (inline rules only), `detect` (matches logged, never blocks), `block` (anomaly-scoring blocking). Unknown values fall back to `detect`. | ### TLS (`tls`) @@ -51,6 +54,7 @@ the server automatically when a startup-only value changed. Note that | Key | Type | Default | Description | |-----|------|---------|-------------| +| `database.type` | string | `"sqlite"` | Database backend. `sqlite` (or empty) is the only supported value — any other value makes the server refuse to start. | | `database.path` | string | `"data/chatserver.db"` | Path to SQLite database file | ### Uploads (`upload`) @@ -104,13 +108,14 @@ Controls the tiered event log used for WebSocket reconnection replay. When enabl ### Telemetry / OpenTelemetry (`telemetry`) -Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contributing](contributing.md)). When disabled, the server uses no-op tracer/meter providers; the legacy JSON `/api/v1/metrics` endpoint is always available regardless of this setting. +Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contributing](contributing.md)). When disabled, the server uses no-op tracer/meter providers; the legacy JSON `/api/v1/metrics` endpoint exists regardless of this setting (it is admin-IP-restricted, like all metrics surfaces). | Key | Type | Default | Description | |-----|------|---------|-------------| | `telemetry.enabled` | bool | `false` | Enable the OTel SDK | | `telemetry.exporter` | string | `"none"` | Exporter backend: `none`, `prometheus`, `otlp` | | `telemetry.otlp_endpoint` | string | `""` | gRPC endpoint for the OTLP exporter (e.g. `localhost:4317`). Only used when `exporter: otlp`. | +| `telemetry.otlp_insecure` | bool | `false` | Disable TLS for the OTLP gRPC connection. Only set `true` in development / private-network deployments. | | `telemetry.service_name` | string | `"owncord-server"` | OTel `service.name` resource attribute | > **Local development:** Run `make otel-up` (from `Server/`) to start Jaeger + Prometheus via Docker. @@ -118,7 +123,7 @@ Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contri ### Plugins (`plugins`) -Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. When disabled, no plugins are loaded and the plugin admin endpoints return `501 Not Implemented`. +Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. When disabled, no plugins are loaded; plugin admin lifecycle endpoints return `503 Service Unavailable` and the plugin list endpoint returns an empty list. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -145,11 +150,22 @@ never ships in the desktop bundle — the client only ever calls > manager) over writing it into `config.yaml`, and rotate it if it has ever > been exposed to a client build. +### Logging (`logging`) + +Controls server log verbosity. The level gates both stdout and the in-memory +ring buffer that backs the admin panel's live log view. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `logging.level` | string | `"info"` | Minimum level logged: `debug`, `info`, `warn`, `error`. Empty = `info`; an unrecognised value falls back to `info` with a startup warning. | + ## Environment Variable Overrides Every config key can be overridden via environment variables using the prefix `OWNCORD_`. -**Format:** `OWNCORD_<SECTION>_<KEY>` +**Format:** `OWNCORD_<SECTION>_<KEY>` — the first `_` after the prefix maps to +the section/key dot; the scheme covers **every** key in the file, including ones +absent from the table below (it is a representative subset, not the full list). | Environment Variable | Config Path | |---------------------|-------------| @@ -178,6 +194,10 @@ Every config key can be overridden via environment variables using the prefix `O | `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` | | `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` | | `OWNCORD_GIF_API_KEY` | `gif.api_key` | +| `OWNCORD_SERVER_WAF_ENABLED` | `server.waf_enabled` | +| `OWNCORD_DATABASE_TYPE` | `database.type` | +| `OWNCORD_TELEMETRY_OTLP_INSECURE` | `telemetry.otlp_insecure` | +| `OWNCORD_LOGGING_LEVEL` | `logging.level` | ## Example config.yaml @@ -251,6 +271,11 @@ plugins: # Prefer OWNCORD_GIF_API_KEY over storing the key in this file. gif: api_key: "" + +# Logging. "level" gates what is logged, to stdout and the admin panel's live +# log view alike. Override without editing this file via OWNCORD_LOGGING_LEVEL. +logging: + level: "info" # debug | info | warn | error ``` ## See Also