diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea97502..70a0bb21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,7 @@ jobs: libgtk-3-dev \ libayatana-appindicator3-dev \ libsecret-1-dev \ + libdbus-1-dev \ libasound2-dev \ libssl-dev \ librsvg2-dev @@ -227,16 +228,17 @@ jobs: - name: Rust unit tests run: cargo test --lib - # Playwright e2e against the mocked-Tauri dev server. Non-blocking, and it - # will very likely be RED at first: the suite has never run in CI, and a - # local run of the 255 web tests on `main` itself fails ~229 of them, all - # cascading from the shared login helper in tests/e2e/helpers.ts - # (navigateToMainPage never sees [data-testid='app-layout']). That breakage - # predates this PR — it reproduces on a clean 70caa6c worktree. + # 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. # - # The job is wired up anyway so the breakage is visible instead of invisible, - # but it MUST stay continue-on-error until the suite is repaired, and - # timeout-minutes caps the minutes it can burn while it is failing. + # 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. # 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. @@ -331,6 +333,7 @@ jobs: libgtk-3-dev \ libayatana-appindicator3-dev \ libsecret-1-dev \ + libdbus-1-dev \ libasound2-dev \ libssl-dev \ patchelf \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 608f6649..d95fecfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,7 @@ jobs: libgtk-3-dev \ libayatana-appindicator3-dev \ libsecret-1-dev \ + libdbus-1-dev \ libasound2-dev \ libssl-dev \ patchelf \ @@ -207,6 +208,7 @@ jobs: libgtk-3-dev \ libayatana-appindicator3-dev \ libsecret-1-dev \ + libdbus-1-dev \ libasound2-dev \ libssl-dev \ patchelf \ diff --git a/.gitignore b/.gitignore index 838b4b72..8f4a009a 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ Client/tauri-client/.env # Claude Code worktrees (local scratch, never commit) .claude/worktrees/ + +# local server run logs +server.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 64364e5b..067aeb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,22 @@ behavioural changes operators must know about. ### Behavioural changes operators must know about +- **The desktop client now actually uses the OS credential store.** The + `keyring` crate declares no `default` feature, so the previous + `keyring = "3"` dependency compiled its in-memory *mock* store on + Windows, macOS and Linux alike: saves reported success and the next + read in the same process returned nothing, and no credential was ever + written to Credential Manager / Keychain / Secret Service. The visible + symptom was the voice-E2EE identity keypair being regenerated, so the + published identity key stopped matching the key that signed the voice + announce and peers rejected it as a possible MITM. The platform + backends are now enabled explicitly and every write is read back + before it is reported as saved. See + [docs/credential-storage.md](docs/credential-storage.md). + - **Linux builds need a new system package, `libdbus-1-dev`**, for the + Secret Service backend. CI and release workflows install it already. + - Users on an affected machine are logged in again and re-verified by + their peers once, then persist normally. - **`event_persistence.enabled` defaults to `true`.** Every broadcast WebSocket event is written to the `events` table, retained for 24 hours by default, and pruned by a background goroutine every hour. diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index ed92685a..9117dc41 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -21,7 +21,7 @@ "livekit-client": "^2.21.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@playwright/test": "^1", "@stryker-mutator/api": "^9.6.1", "@stryker-mutator/core": "^9.6.1", @@ -30,7 +30,7 @@ "@tauri-apps/cli": "^2", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "jsdom": "^29.1.1", "knip": "^6.1.1", "oxlint": "^1.76.0", @@ -1352,167 +1352,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { @@ -1929,24 +1851,6 @@ } } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2173,9 +2077,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2193,9 +2094,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2213,9 +2111,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2233,9 +2128,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2253,9 +2145,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2273,9 +2162,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2293,9 +2179,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2313,9 +2196,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2528,9 +2408,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2545,9 +2422,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2562,9 +2436,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2579,9 +2450,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2596,9 +2464,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2613,9 +2478,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2630,9 +2492,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2647,9 +2506,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2844,9 +2700,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2864,9 +2717,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2884,9 +2734,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2904,9 +2751,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2924,9 +2768,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2944,9 +2785,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2964,9 +2802,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2984,9 +2819,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3064,17 +2896,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.62.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", @@ -3738,9 +3559,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3758,9 +3576,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3778,9 +3593,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3798,9 +3610,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3818,9 +3627,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -4030,6 +3836,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4274,19 +4087,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/browser": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz", @@ -4473,9 +4273,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -4522,39 +4322,6 @@ "node": ">= 14" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4708,16 +4475,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -4756,39 +4513,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -4816,26 +4540,6 @@ "node": ">= 12" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -4846,13 +4550,6 @@ "node": ">=20" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4995,13 +4692,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.396", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", @@ -5009,13 +4699,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -5135,33 +4818,33 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5171,8 +4854,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5180,7 +4862,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -5195,79 +4877,50 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5535,23 +5188,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -5673,22 +5309,18 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5707,52 +5339,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5859,23 +5445,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5903,16 +5472,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6033,22 +5592,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -6082,29 +5625,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -6146,16 +5666,6 @@ } } }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6329,13 +5839,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -6357,11 +5860,14 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/lz-string": { "version": "1.5.0", @@ -6436,13 +5942,13 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6772,26 +6278,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -6839,17 +6325,17 @@ } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7102,16 +6588,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -7406,110 +6882,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -7523,19 +6895,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -7577,18 +6936,18 @@ "license": "MIT" }, "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", + "glob": "^13.0.6", "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, "node_modules/tinybench": { @@ -8207,104 +7566,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index 3aaf018c..2ef74397 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -30,7 +30,7 @@ "test:mutate:dry": "stryker run --dryRunOnly" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@playwright/test": "^1", "@stryker-mutator/api": "^9.6.1", "@stryker-mutator/core": "^9.6.1", @@ -39,7 +39,7 @@ "@tauri-apps/cli": "^2", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "jsdom": "^29.1.1", "knip": "^6.1.1", "oxlint": "^1.76.0", @@ -72,6 +72,7 @@ "livekit-client": "^2.21.0" }, "overrides": { - "qs": "^6.15.3" + "qs": "^6.15.3", + "test-exclude": "^8.0.0" } } diff --git a/Client/tauri-client/playwright.config.ts b/Client/tauri-client/playwright.config.ts index 89c572aa..f15575bc 100644 --- a/Client/tauri-client/playwright.config.ts +++ b/Client/tauri-client/playwright.config.ts @@ -11,8 +11,19 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1, workers: process.env.CI ? 1 : undefined, + // CI fail-fast: a systemic breakage (e.g. the shared login helper) makes + // most of the 255 tests burn their full timeout × retries — hours of runner + // time at 1 worker. Abort after 20 failures instead so the job reports a + // usable red quickly. 0 = unlimited (local runs see every failure). + maxFailures: process.env.CI ? 20 : 0, + // Self-terminate before the workflow's timeout-minutes (25) SIGKILLs the + // runner, so the HTML/JUnit report still gets written and uploaded. + globalTimeout: process.env.CI ? 20 * 60 * 1000 : 0, reporter: process.env.CI - ? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + ? [ + ["html", { open: "never" }], + ["junit", { outputFile: "test-results/junit.xml" }], + ] : "html", use: { diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index ec00b1e7..c4b7b65f 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -351,6 +362,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -507,6 +527,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.57" @@ -603,6 +632,16 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -975,6 +1014,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1059,6 +1116,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1973,6 +2031,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2119,7 +2195,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.58.0", ] [[package]] @@ -2316,6 +2392,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2457,7 +2543,13 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "dbus-secret-service", "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", "zeroize", ] @@ -2760,6 +2852,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2777,7 +2882,40 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.14.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", ] [[package]] @@ -2786,6 +2924,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3108,6 +3276,7 @@ dependencies = [ name = "owncord-client" version = "1.1.0-alpha.3" dependencies = [ + "base64 0.22.1", "device_query", "futures-util", "keyring", @@ -3139,6 +3308,8 @@ dependencies = [ "url", "webpki-roots 1.0.9", "windows 0.58.0", + "windows-sys 0.60.2", + "zeroize", ] [[package]] @@ -3469,13 +3640,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml 0.38.4", + "quick-xml", "serde", "time", ] @@ -3652,18 +3823,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -4169,7 +4331,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4197,7 +4359,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4307,6 +4469,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.7", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4705,6 +4899,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -5222,7 +5422,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -5249,7 +5449,7 @@ dependencies = [ "tokio", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -5441,11 +5641,10 @@ dependencies = [ [[package]] name = "tauri-winrt-notification" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "quick-xml 0.37.5", "thiserror 2.0.18", "windows 0.61.3", "windows-version", @@ -7168,6 +7367,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7191,6 +7400,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.14.0" @@ -7221,9 +7462,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.14.0", + "zbus_names 4.3.1", + "zvariant 5.10.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7236,9 +7490,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7249,7 +7514,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.15", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -7298,6 +7563,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7350,6 +7629,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.10.0" @@ -7360,8 +7652,21 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7374,7 +7679,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 3a8c7b19..ec225fd4 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -63,7 +63,37 @@ log = "0.4" # TS client-logs) so a shipped user can retrieve them — a release build detaches # the console, so stdout/stderr logging is otherwise unreachable. tauri-plugin-log = "2" -keyring = "3" +# The backend features are NOT optional extras — keyring 3.x declares no +# `default` feature at all, and every platform arm in its lib.rs falls back to +# `pub use mock as default` when its backend feature is off. A bare +# `keyring = "3"` therefore compiles the in-memory mock store on Windows, macOS +# AND Linux: `set_password` succeeds into a per-Entry cell that is dropped when +# the Entry goes out of scope, and the next `Entry::new(..).get_password()` +# returns NoEntry. Nothing ever reaches Credential Manager / Keychain / +# Secret Service. Removing any of these silently reverts a platform to that +# store — `secret_store::tests::compiled_keyring_backend_is_persistent` fails +# the build if that happens. +# windows-native -> Windows Credential Manager (DPAPI-backed) +# apple-native -> macOS Keychain +# sync-secret-service -> Secret Service (GNOME Keyring / KWallet) over libdbus. +# Chosen over async-secret-service because our Tauri +# commands are blocking `fn`s on Tauri's worker pool; +# the async backend would need a nested runtime. +# Build-time system dep: libdbus-1-dev. +# crypto-rust -> pure-Rust session crypto for the Secret Service +# transport (avoids linking OpenSSL for it). +keyring = { version = "3", default-features = false, features = [ + "windows-native", + "apple-native", + "sync-secret-service", + "crypto-rust", +] } +# Scrubs the plaintext secret copies that the DPAPI fallback has to materialize +# as `Vec` for the Win32 call. +zeroize = "1" +# Encodes the DPAPI ciphertext for the JSON fallback store. Already in the tree +# via the tauri/rustls stack, so this costs no extra build. +base64 = "0.22" rfd = { version = "0.16", default-features = false } # Desktop-only plugins (no mobile bundle target). single-instance carries the @@ -77,6 +107,13 @@ tauri-plugin-deep-link = "2" [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] } +# DPAPI (CryptProtectData/CryptUnprotectData) for the last-resort credential +# fallback in secret_store. Version tracks keyring's own windows-sys dep so the +# two share one build of the crate. +windows-sys = { version = "0.60", features = [ + "Win32_Foundation", + "Win32_Security_Cryptography", +] } [target.'cfg(target_os = "linux")'.dependencies] device_query = "2" diff --git a/Client/tauri-client/src-tauri/src/constants.rs b/Client/tauri-client/src-tauri/src/constants.rs index 434daa8f..6e7496cd 100644 --- a/Client/tauri-client/src-tauri/src/constants.rs +++ b/Client/tauri-client/src-tauri/src/constants.rs @@ -6,3 +6,8 @@ pub const IDENTITY_PINS_STORE: &str = "identity_pins.json"; /// Tauri store file for user settings and preferences. pub const SETTINGS_STORE: &str = "settings.json"; + +/// Tauri store file for the degraded-mode credential fallback (see +/// `secret_store`). Values are DPAPI ciphertext, never plaintext, and the file +/// only exists on a machine whose OS credential store failed a round-trip. +pub const CREDENTIAL_FALLBACK_STORE: &str = "credential_fallback.json"; diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 06d7f855..0281f33b 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -1,7 +1,7 @@ -use keyring::Entry; use serde::Serialize; +use tauri::AppHandle; -const SERVICE: &str = "com.owncord.client"; +use crate::secret_store::{self, Backend}; /// Data returned from `load_credential`. #[derive(Serialize, Clone)] @@ -24,6 +24,35 @@ impl std::fmt::Debug for CredentialData { } } +// --------------------------------------------------------------------------- +// Account naming +// --------------------------------------------------------------------------- +// +// Both secrets live in the same credential-store service +// (`secret_store::SERVICE`) and are told apart by their account name. Changing +// either function orphans every credential already stored under the old name, +// so they are pure and covered by tests. + +/// Account holding the login credential for `host`. +fn login_account(host: &str) -> String { + host.to_string() +} + +/// Account holding the voice-E2EE identity private key for `host`. +/// +/// The `identity:` prefix keeps it distinct from the login credential for the +/// same host; a collision would make one secret overwrite the other. +fn identity_account(host: &str) -> String { + format!("identity:{host}") +} + +fn require_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{field} must not be empty")); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -36,23 +65,20 @@ impl std::fmt::Debug for CredentialData { /// /// On Windows the secret is protected by DPAPI via Windows Credential Manager. /// On Linux it is stored in the Secret Service (GNOME Keyring / KWallet). -/// On macOS it is stored in the system Keychain. +/// On macOS it is stored in the system Keychain. The write is read back before +/// this returns — see [`crate::secret_store`] for what happens when it does not +/// come back. #[tauri::command] pub fn save_credential( + app: AppHandle, host: String, username: String, token: String, password: Option, ) -> Result<(), String> { - if host.is_empty() { - return Err("host must not be empty".into()); - } - if token.is_empty() { - return Err("token must not be empty".into()); - } - if username.is_empty() { - return Err("username must not be empty".into()); - } + require_non_empty(&host, "host")?; + require_non_empty(&token, "token")?; + require_non_empty(&username, "username")?; let mut payload = serde_json::json!({ "username": username, @@ -62,12 +88,8 @@ pub fn save_credential( payload["password"] = serde_json::Value::String(pw.clone()); } - let entry = - Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?; - entry - .set_password(&payload.to_string()) + secret_store::set(&app, &login_account(&host), &payload.to_string()) .map_err(|e| format!("save_credential failed: {e}"))?; - Ok(()) } @@ -75,21 +97,24 @@ pub fn save_credential( /// /// Returns `None` when no credential exists for the given host. #[tauri::command] -pub fn load_credential(host: String) -> Result, String> { - if host.is_empty() { - return Err("host must not be empty".into()); - } +pub fn load_credential(app: AppHandle, host: String) -> Result, String> { + require_non_empty(&host, "host")?; - let entry = - Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?; - - let json_str = match entry.get_password() { - Ok(s) => s, - Err(keyring::Error::NoEntry) => return Ok(None), - Err(e) => return Err(format!("load_credential failed: {e}")), + let Some(json_str) = secret_store::get(&app, &login_account(&host)) + .map_err(|e| format!("load_credential failed: {e}"))? + else { + return Ok(None); }; - let parsed: serde_json::Value = serde_json::from_str(&json_str) + parse_credential_blob(&json_str).map(Some) +} + +/// Parse the stored credential JSON blob. +/// +/// Split out from the command so the blob contract is testable without a +/// credential store. +fn parse_credential_blob(json_str: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(json_str) .map_err(|e| format!("credential blob is not valid JSON: {e}"))?; let username = parsed @@ -107,26 +132,21 @@ pub fn load_credential(host: String) -> Result, String> { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - Ok(Some(CredentialData { username, token, password })) + Ok(CredentialData { + username, + token, + password, + }) } /// Delete a credential from the system credential store. /// /// Deleting a non-existent credential is not treated as an error. #[tauri::command] -pub fn delete_credential(host: String) -> Result<(), String> { - if host.is_empty() { - return Err("host must not be empty".into()); - } - - let entry = - Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?; - - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(format!("delete_credential failed: {e}")), - } +pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> { + require_non_empty(&host, "host")?; + secret_store::delete(&app, &login_account(&host)) + .map_err(|e| format!("delete_credential failed: {e}")) } // --------------------------------------------------------------------------- @@ -134,28 +154,23 @@ pub fn delete_credential(host: String) -> Result<(), String> { // --------------------------------------------------------------------------- // // Mirrors save/load/delete_credential, but the secret is a single opaque -// key blob (base64 PKCS8 private key) rather than a JSON credential struct, +// key blob (base64 JWK private key) rather than a JSON credential struct, // and it is stored under account `identity:{host}` to keep it distinct from -// the login credential entry (account `{host}`) in the same keyring service. +// the login credential entry (account `{host}`) in the same service. -/// Save the long-term identity private key for `host` to the system credential -/// store, under account `identity:{host}`. +/// Save the long-term identity private key for `host`. +/// +/// The write is read back before this returns. A machine whose credential store +/// accepts writes without keeping them falls through to the DPAPI file; if that +/// is also unavailable this returns an error rather than reporting a success +/// that would leave peers rejecting the user's voice announce after a restart. #[tauri::command] -pub fn save_identity_key(host: String, key: String) -> Result<(), String> { - if host.is_empty() { - return Err("host must not be empty".into()); - } - if key.is_empty() { - return Err("key must not be empty".into()); - } +pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> { + require_non_empty(&host, "host")?; + require_non_empty(&key, "key")?; - let account = format!("identity:{host}"); - let entry = - Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; - entry - .set_password(&key) + secret_store::set(&app, &identity_account(&host), &key) .map_err(|e| format!("save_identity_key failed: {e}"))?; - Ok(()) } @@ -163,39 +178,81 @@ pub fn save_identity_key(host: String, key: String) -> Result<(), String> { /// /// Returns `None` when no identity key exists for the given host. #[tauri::command] -pub fn load_identity_key(host: String) -> Result, String> { - if host.is_empty() { - return Err("host must not be empty".into()); - } - - let account = format!("identity:{host}"); - let entry = - Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; - - match entry.get_password() { - Ok(s) => Ok(Some(s)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(format!("load_identity_key failed: {e}")), - } +pub fn load_identity_key(app: AppHandle, host: String) -> Result, String> { + require_non_empty(&host, "host")?; + secret_store::get(&app, &identity_account(&host)) + .map_err(|e| format!("load_identity_key failed: {e}")) } /// Delete the identity private key for `host`. /// /// Deleting a non-existent key is not treated as an error. #[tauri::command] -pub fn delete_identity_key(host: String) -> Result<(), String> { - if host.is_empty() { - return Err("host must not be empty".into()); +pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> { + require_non_empty(&host, "host")?; + secret_store::delete(&app, &identity_account(&host)) + .map_err(|e| format!("delete_identity_key failed: {e}")) +} + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/// Result of [`probe_credential_store`]. +#[derive(Serialize, Debug)] +pub struct CredentialStoreProbe { + /// Whether a write/read/delete cycle completed with the value intact. + pub ok: bool, + /// Which store served the probe, when it succeeded. + pub backend: Option, + /// Failure detail, for the log and the support bundle. + pub error: Option, +} + +/// Write, read back and delete a throwaway secret to prove the credential store +/// works on this machine. +/// +/// This is the check to run when a user reports peers rejecting their voice +/// announce: it distinguishes "the credential store is fine" from "writes are +/// accepted and dropped" without touching any real credential. The probe +/// account is removed again whatever the outcome. +#[tauri::command] +pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe { + // Underscores are not legal in DNS hostnames, so this cannot collide with a + // real `{host}` or `identity:{host}` account. + const PROBE_ACCOUNT: &str = "__diagnostic_probe__"; + const PROBE_SECRET: &str = "owncord-credential-store-probe"; + + let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| { + match secret_store::get(&app, PROBE_ACCOUNT)? { + Some(ref got) if got == PROBE_SECRET => Ok(backend), + Some(_) => Err("read back a different value than was written".into()), + None => Err("the store reported a successful write but returned no entry".into()), + } + }); + + // Always clean up, including when the probe failed part-way through. + if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) { + log::warn!("failed to remove credential store probe entry: {e}"); } - let account = format!("identity:{host}"); - let entry = - Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; - - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(format!("delete_identity_key failed: {e}")), + match result { + Ok(backend) => { + log::info!("credential store probe succeeded (backend: {backend:?})"); + CredentialStoreProbe { + ok: true, + backend: Some(backend), + error: None, + } + } + Err(e) => { + log::error!("credential store probe failed: {e}"); + CredentialStoreProbe { + ok: false, + backend: None, + error: Some(e), + } + } } } @@ -208,66 +265,70 @@ mod tests { use super::*; #[test] - fn save_credential_rejects_empty_host() { - let result = save_credential("".into(), "user".into(), "tok".into(), None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); + fn require_non_empty_rejects_empty_and_names_the_field() { + let err = require_non_empty("", "host").unwrap_err(); + assert_eq!(err, "host must not be empty"); + assert_eq!( + require_non_empty("", "token").unwrap_err(), + "token must not be empty" + ); + assert_eq!( + require_non_empty("", "username").unwrap_err(), + "username must not be empty" + ); + assert_eq!( + require_non_empty("", "key").unwrap_err(), + "key must not be empty" + ); } #[test] - fn save_credential_rejects_empty_token() { - let result = save_credential("host".into(), "user".into(), "".into(), None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("token must not be empty")); + fn require_non_empty_accepts_a_value() { + assert!(require_non_empty("chat.example.com", "host").is_ok()); } #[test] - fn save_credential_rejects_empty_username() { - let result = save_credential("host".into(), "".into(), "tok".into(), None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("username must not be empty")); + fn login_and_identity_accounts_never_collide() { + // Both secrets share one credential-store service, so a collision would + // silently overwrite one with the other. + let host = "chat.example.com"; + assert_eq!(login_account(host), "chat.example.com"); + assert_eq!(identity_account(host), "identity:chat.example.com"); + assert_ne!(login_account(host), identity_account(host)); } #[test] - fn load_credential_rejects_empty_host() { - let result = load_credential("".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); + fn account_names_keep_the_port_that_distinguishes_hosts() { + // Two servers on one machine differ only by port; dropping it would + // make them share an identity key. + assert_ne!(login_account("localhost:8443"), login_account("localhost:9443")); + assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443"); } #[test] - fn delete_credential_rejects_empty_host() { - let result = delete_credential("".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); + fn parse_credential_blob_reads_all_fields() { + let data = + parse_credential_blob(r#"{"username":"alice","token":"tok","password":"pw"}"#).unwrap(); + assert_eq!(data.username, "alice"); + assert_eq!(data.token, "tok"); + assert_eq!(data.password.as_deref(), Some("pw")); } #[test] - fn save_identity_key_rejects_empty_host() { - let result = save_identity_key("".into(), "key".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); + fn parse_credential_blob_allows_missing_password() { + let data = parse_credential_blob(r#"{"username":"alice","token":"tok"}"#).unwrap(); + assert_eq!(data.password, None); } #[test] - fn save_identity_key_rejects_empty_key() { - let result = save_identity_key("host".into(), "".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("key must not be empty")); - } - - #[test] - fn load_identity_key_rejects_empty_host() { - let result = load_identity_key("".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); - } - - #[test] - fn delete_identity_key_rejects_empty_host() { - let result = delete_identity_key("".into()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("host must not be empty")); + fn parse_credential_blob_rejects_malformed_input() { + assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON")); + assert!(parse_credential_blob(r#"{"token":"tok"}"#) + .unwrap_err() + .contains("missing 'username'")); + assert!(parse_credential_blob(r#"{"username":"alice"}"#) + .unwrap_err() + .contains("missing 'token'")); } #[test] diff --git a/Client/tauri-client/src-tauri/src/dpapi.rs b/Client/tauri-client/src-tauri/src/dpapi.rs new file mode 100644 index 00000000..e8b19cc4 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/dpapi.rs @@ -0,0 +1,146 @@ +//! Windows DPAPI (Data Protection API) wrappers. +//! +//! Used only by [`crate::secret_store`]'s last-resort fallback: when the OS +//! credential store accepts a write but will not return it, the secret is +//! encrypted here and parked in a file under the app data dir instead. +//! +//! Protection is **user-scoped** (no `CRYPTPROTECT_LOCAL_MACHINE`), so the +//! ciphertext is only decryptable by the same Windows user account on the same +//! machine. `CRYPTPROTECT_UI_FORBIDDEN` guarantees the call never blocks on a +//! prompt — this runs inside a Tauri command, not on a UI thread. +//! +//! This module holds no Tauri types on purpose: it is pure bytes-in/bytes-out +//! so the Win32 surface can be compiled and reviewed on its own. + +use windows_sys::Win32::Foundation::{GetLastError, LocalFree}; +use windows_sys::Win32::Security::Cryptography::{ + CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, +}; +use zeroize::Zeroize; + +/// A Win32 error code from `GetLastError`. +pub type Win32Error = u32; + +/// Owns a `CRYPT_INTEGER_BLOB` that DPAPI allocated for us. +/// +/// DPAPI hands back a `LocalAlloc`ed buffer the caller must release. Wrapping it +/// means an early return or a panic while copying the payload out still frees +/// it, and lets the scrub-then-free order live in one place. +struct OutBlob(CRYPT_INTEGER_BLOB); + +impl OutBlob { + fn to_vec(&self) -> Vec { + if self.0.pbData.is_null() || self.0.cbData == 0 { + return Vec::new(); + } + // SAFETY: only constructed after DPAPI reported success, which means + // pbData points at cbData initialized bytes. + unsafe { std::slice::from_raw_parts(self.0.pbData, self.0.cbData as usize) }.to_vec() + } +} + +impl Drop for OutBlob { + fn drop(&mut self) { + if self.0.pbData.is_null() { + return; + } + // Scrub first: on the unprotect path this buffer holds the plaintext + // identity key, and LocalFree does not zero what it releases. + // SAFETY: as in `to_vec`, plus the range is ours alone to write. + let bytes = unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) }; + bytes.zeroize(); + // SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most + // once, so it is freed exactly once. + unsafe { LocalFree(self.0.pbData.cast()) }; + } +} + +/// Build an input blob over `buf`. +/// +/// `CRYPT_INTEGER_BLOB::pbData` is `*mut u8` even for inputs DPAPI only reads, +/// so callers lend a mutable buffer rather than casting away a `&`. +fn in_blob(buf: &mut [u8]) -> CRYPT_INTEGER_BLOB { + CRYPT_INTEGER_BLOB { + cbData: buf.len() as u32, + pbData: buf.as_mut_ptr(), + } +} + +fn empty_out() -> CRYPT_INTEGER_BLOB { + CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + } +} + +/// Encrypt `plaintext` with the current user's DPAPI master key. +/// +/// `entropy` is mixed into the key derivation, so a blob protected for one +/// account cannot be decrypted as another even if the file is edited by hand. +pub fn protect(plaintext: &[u8], entropy: &[u8]) -> Result, Win32Error> { + // Both buffers must be mutable to be addressed by CRYPT_INTEGER_BLOB, and + // `plaintext` is key material, so these are scrubbed local copies. + let mut input = plaintext.to_vec(); + let mut entropy = entropy.to_vec(); + let mut out = empty_out(); + + let in_b = in_blob(&mut input); + let ent_b = in_blob(&mut entropy); + // SAFETY: `in_b`/`ent_b` borrow live, correctly sized buffers that outlive + // the call; the description, reserved and prompt-struct pointers are null, + // which the API documents as "not supplied"; `out` is a valid destination + // that is only read after the return value is checked. + let ok = unsafe { + CryptProtectData( + &in_b, + std::ptr::null(), + &ent_b, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + + input.zeroize(); + entropy.zeroize(); + finish(ok, out) +} + +/// Inverse of [`protect`]. Fails if the blob was protected by a different user, +/// on a different machine, or with different `entropy`. +pub fn unprotect(ciphertext: &[u8], entropy: &[u8]) -> Result, Win32Error> { + let mut input = ciphertext.to_vec(); + let mut entropy = entropy.to_vec(); + let mut out = empty_out(); + + let in_b = in_blob(&mut input); + let ent_b = in_blob(&mut entropy); + // SAFETY: as in `protect`. The extra `*mut PWSTR` out-param is the + // description string, which is null here to decline it. + let ok = unsafe { + CryptUnprotectData( + &in_b, + std::ptr::null_mut(), + &ent_b, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + + entropy.zeroize(); + finish(ok, out) +} + +/// Turn a Win32 `BOOL` plus its out-blob into a `Result`. +/// +/// On failure DPAPI allocates nothing, so there is no blob to release. +fn finish(ok: i32, out: CRYPT_INTEGER_BLOB) -> Result, Win32Error> { + if ok == 0 { + // SAFETY: no preconditions; reads the calling thread's last error. + return Err(unsafe { GetLastError() }); + } + Ok(OutBlob(out).to_vec()) +} diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 4b14958d..8ccb4ec5 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -1,9 +1,12 @@ mod commands; mod constants; mod credentials; +#[cfg(windows)] +mod dpapi; mod http_proxy; mod livekit_proxy; mod ptt; +mod secret_store; mod tofu; mod tray; mod update_commands; @@ -111,6 +114,7 @@ pub fn run() { credentials::save_identity_key, credentials::load_identity_key, credentials::delete_identity_key, + credentials::probe_credential_store, update_commands::check_client_update, update_commands::download_and_install_update, ptt::ptt_start, @@ -127,6 +131,9 @@ pub fn run() { ]) .setup(|app| { // Rust logging is initialized by tauri_plugin_log (registered above). + // Record the credential backend first: if this build has no + // persistent store, every later credential symptom follows from it. + secret_store::log_compiled_backend(); tray::create_tray(app.handle())?; Ok(()) }) diff --git a/Client/tauri-client/src-tauri/src/secret_store.rs b/Client/tauri-client/src-tauri/src/secret_store.rs new file mode 100644 index 00000000..feb2f7c4 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/secret_store.rs @@ -0,0 +1,391 @@ +//! Secret storage with a verified round-trip and a degraded-mode fallback. +//! +//! Every secret the client persists (the login credential and the voice-E2EE +//! long-term identity key) goes through here. The OS credential store is always +//! tried first and is the only store used on a healthy machine. +//! +//! # Why a write is verified +//! +//! `Entry::set_password` returning `Ok(())` does not mean the secret is +//! readable. This bit us for real: `keyring` 3.x declares no `default` feature, +//! and every platform arm in its `lib.rs` falls back to `pub use mock as +//! default` when the platform's backend feature is off. Built as a bare +//! `keyring = "3"`, the client shipped with the **mock** store on all three +//! desktop platforms — an in-memory cell owned by the `Entry` itself: +//! +//! ```text +//! save_identity_key -> Entry::new(..) -> set_password -> Ok(()) // Entry dropped here +//! load_identity_key -> Entry::new(..) -> get_password -> NoEntry // brand-new empty cell +//! ``` +//! +//! So a save reported success and the very next read in the same process +//! returned nothing, with no error anywhere and nothing ever written to +//! Credential Manager. Downstream, the identity keypair was regenerated on +//! every reconnect, the published key stopped matching the key that signed the +//! voice announce, and peers correctly rejected the announce as a forged +//! signature. `Cargo.toml` now names the backend features explicitly and +//! [`tests::compiled_keyring_backend_is_persistent`] fails the build if they +//! are ever dropped again — but a store that lies about a write is exactly the +//! failure a `Result` cannot express, so writes are read back regardless. +//! +//! # Fallback policy +//! +//! The keychain is the right store; the fallback is damage control, not a +//! default. It engages only after a write has been proven not to round-trip, +//! and only on Windows, where DPAPI can protect the file at rest with a +//! user-scoped key. On macOS and Linux a failing Keychain / Secret Service is +//! reported as an error rather than silently downgraded to a file — writing a +//! login password or an identity private key to plaintext disk there would be a +//! worse outcome than not persisting it. + +use serde::Serialize; +// Only the DPAPI fallback stores JSON values, and that is Windows-only. +#[cfg(windows)] +use serde_json::Value; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +use crate::constants::CREDENTIAL_FALLBACK_STORE; + +/// Credential-store service name. Shared by every account this module stores. +pub const SERVICE: &str = "com.owncord.client"; + +/// Which store actually holds a secret. +/// +/// Variant names are serialized verbatim: `tauri-typegen` does not read serde +/// rename attributes, so a `rename_all` here would silently make the generated +/// TypeScript union disagree with the values actually sent over IPC. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Backend { + /// The OS credential store. The expected answer on every healthy machine. + Keyring, + /// DPAPI-protected file under the app data dir, used only after the OS + /// credential store accepted a write and then failed to return it. + DpapiFile, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Store `secret` under `account`, and prove it can be read back. +/// +/// Returns which backend ended up holding it. An `Err` means no store kept the +/// 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 { + match keyring_set(account, secret) { + Ok(()) => match keyring_get(account) { + // The normal path: written and read back byte-for-byte. + Ok(Some(ref got)) if got == secret => { + // 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); + return Ok(Backend::Keyring); + } + Ok(Some(_)) => { + log::error!( + "{SERVICE}: credential store returned a different secret than was written \ + for account '{account}' — falling back" + ); + // Purge it. `get` reads the credential store first, so leaving + // a value we did not write in place would shadow the fallback + // copy written below — handing the caller an identity key whose + // public half was never published, which is the exact failure + // this module exists to prevent. + if let Err(e) = keyring_delete(account) { + log::warn!( + "{SERVICE}: could not remove the mismatched entry for '{account}': {e}" + ); + } + } + Ok(None) => log::error!( + "{SERVICE}: credential store accepted the write for account '{account}' \ + but reports no entry on read-back — falling back" + ), + Err(e) => log::error!( + "{SERVICE}: credential store accepted the write for account '{account}' \ + but the read-back failed: {e} — falling back" + ), + }, + Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"), + } + + set_fallback(app, account, secret)?; + log::warn!( + "{SERVICE}: account '{account}' is stored in the DPAPI fallback file, not the OS \ + credential store. See docs/credential-storage.md" + ); + Ok(Backend::DpapiFile) +} + +/// Load the secret for `account`, or `None` when nothing is stored. +/// +/// 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, 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(get_fallback(app, account)) +} + +/// Remove `account` from every store. Absent entries are not an error. +/// +/// Both stores are cleared even if one errors: a delete that left the fallback +/// copy behind would resurrect a "deleted" secret on the next read. +pub fn delete(app: &AppHandle, account: &str) -> Result<(), String> { + let keyring_result = keyring_delete(account); + clear_fallback(app, account); + keyring_result +} + +// --------------------------------------------------------------------------- +// Compiled-backend introspection +// --------------------------------------------------------------------------- + +/// Whether the `keyring` backend compiled into this build keeps secrets on disk. +/// +/// `keyring` picks its backend at compile time and falls back to the in-memory +/// mock when a platform's feature is missing, so this is a property of the +/// build, not of the machine. `CredentialPersistence` is `#[non_exhaustive]` +/// and carries no `Debug`, hence the explicit description. +fn compiled_backend_persistence() -> (bool, &'static str) { + // `CredentialBuilderApi` needs no import: `default_credential_builder` + // returns a `dyn` trait object, whose methods resolve without it. + use keyring::credential::CredentialPersistence; + + match keyring::default::default_credential_builder().persistence() { + CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"), + CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"), + CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"), + CredentialPersistence::EntryOnly => { + (false, "vanishes with the entry object (the in-memory mock store)") + } + _ => (false, "unrecognized persistence class"), + } +} + +/// Record the compiled credential backend in the log file at startup. +/// +/// A shipped release build has no console, so the log file is the only place a +/// user can be asked to look. Stating the backend there turns "my identity key +/// keeps changing" into a one-line answer. +pub fn log_compiled_backend() { + let (persistent, description) = compiled_backend_persistence(); + if persistent { + log::info!("credential store: OS keyring, {description}"); + } else { + log::error!( + "credential store: NO persistent backend compiled in — {description}. Credentials \ + and the voice-E2EE identity key will not survive a restart. This is a build \ + configuration fault, not a machine fault: check the keyring backend features in \ + src-tauri/Cargo.toml." + ); + } +} + +// --------------------------------------------------------------------------- +// OS credential store +// --------------------------------------------------------------------------- + +fn entry(account: &str) -> Result { + keyring::Entry::new(SERVICE, account).map_err(|e| format!("keyring entry error: {e}")) +} + +fn keyring_set(account: &str, secret: &str) -> Result<(), String> { + entry(account)? + .set_password(secret) + .map_err(|e| format!("{e}")) +} + +fn keyring_get(account: &str) -> Result, String> { + match entry(account)?.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("{e}")), + } +} + +fn keyring_delete(account: &str) -> Result<(), String> { + match entry(account)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("delete failed: {e}")), + } +} + +// --------------------------------------------------------------------------- +// Degraded-mode fallback (Windows only, DPAPI-protected) +// --------------------------------------------------------------------------- + +/// Entropy bound into the DPAPI blob for `account`. +/// +/// Including the service and account means a ciphertext lifted from one entry +/// cannot be pasted over another and still decrypt — the identity key for one +/// host cannot be made to load as another's. +#[cfg(windows)] +fn dpapi_entropy(account: &str) -> Vec { + format!("{SERVICE}\u{1}{account}").into_bytes() +} + +#[cfg(windows)] +fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), String> { + use base64::Engine as _; + + let blob = crate::dpapi::protect(secret.as_bytes(), &dpapi_entropy(account)) + .map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))?; + let encoded = base64::engine::general_purpose::STANDARD.encode(blob); + + let store = app + .store(CREDENTIAL_FALLBACK_STORE) + .map_err(|e| format!("failed to open credential fallback store: {e}"))?; + let old = store.get(account); + store.set(account, Value::String(encoded)); + if let Err(e) = store.save() { + // Restore the previous in-memory state so a failed flush cannot drop a + // credential that was already parked here. + match old { + Some(v) => store.set(account, v), + None => { + let _ = store.delete(account); + } + } + return Err(format!("failed to persist credential fallback: {e}")); + } + Ok(()) +} + +#[cfg(not(windows))] +fn set_fallback(_app: &AppHandle, account: &str, _secret: &str) -> Result<(), String> { + // Deliberately no file fallback here: see the module header. The Keychain + // and Secret Service are the right stores on these platforms, and a + // plaintext file holding a login password or an identity private key is a + // worse outcome than failing to persist. + Err(format!( + "the OS credential store did not accept '{account}' and there is no fallback store on \ + this platform — check that the Keychain (macOS) or a Secret Service provider such as \ + gnome-keyring / KWallet (Linux) is running and unlocked" + )) +} + +#[cfg(windows)] +fn get_fallback(app: &AppHandle, account: &str) -> Option { + use base64::Engine as _; + + let store = app + .store(CREDENTIAL_FALLBACK_STORE) + .map_err(|e| log::warn!("failed to open credential fallback store: {e}")) + .ok()?; + let encoded = match store.get(account) { + Some(Value::String(s)) => s, + _ => return None, + }; + let blob = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|e| log::warn!("credential fallback entry for '{account}' is not base64: {e}")) + .ok()?; + let plaintext = crate::dpapi::unprotect(&blob, &dpapi_entropy(account)) + .map_err(|code| { + log::warn!("DPAPI unprotect failed for '{account}' (Win32 error {code}) — the entry \ + was written by a different Windows user or on a different machine") + }) + .ok()?; + String::from_utf8(plaintext) + .map_err(|_| log::warn!("credential fallback entry for '{account}' is not valid UTF-8")) + .ok() +} + +#[cfg(not(windows))] +fn get_fallback(_app: &AppHandle, _account: &str) -> Option { + None +} + +/// Drop any fallback copy of `account`. Best-effort: a failure here is logged, +/// never propagated, because it must not mask the outcome of the real store. +fn clear_fallback(app: &AppHandle, account: &str) { + let Ok(store) = app.store(CREDENTIAL_FALLBACK_STORE) else { + return; + }; + // `delete` reports whether a key was present; only flush when one was, so + // the common healthy path does not rewrite the file on every save. + if store.delete(account) { + if let Err(e) = store.save() { + log::warn!("failed to flush credential fallback removal for '{account}': {e}"); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression guard for the bug this module exists to prevent. + /// + /// `keyring` has no `default` feature: with the backend features missing it + /// silently compiles the in-memory mock store, whose writes never survive + /// the `Entry` that made them. This asserts the backend linked into *this* + /// build persists to disk, so dropping the features from `Cargo.toml` is a + /// test failure rather than a silent loss of credential storage on a user's + /// machine. It needs no live keychain — it inspects the compiled backend. + #[test] + fn compiled_keyring_backend_is_persistent() { + let (persistent, description) = compiled_backend_persistence(); + assert!( + persistent, + "keyring compiled a non-persistent backend ({description}); the platform backend \ + features in Cargo.toml (windows-native / apple-native / sync-secret-service) are \ + missing or a platform arm fell through to `mock`" + ); + } + + #[test] + fn service_name_is_stable() { + // The service name is half of the credential's identity; changing it + // orphans every already-stored credential. + assert_eq!(SERVICE, "com.owncord.client"); + } + + /// Pins the IPC wire format to the variant names, which is what + /// `tauri-typegen` emits into `generated/types.ts` as + /// `type Backend = "Keyring" | "DpapiFile"`. Renaming a variant, or adding + /// a serde rename, desyncs the generated union from the runtime value. + #[test] + fn backend_serializes_as_its_variant_name() { + assert_eq!( + serde_json::to_string(&Backend::Keyring).unwrap(), + "\"Keyring\"" + ); + assert_eq!( + serde_json::to_string(&Backend::DpapiFile).unwrap(), + "\"DpapiFile\"" + ); + } + + #[cfg(windows)] + #[test] + fn dpapi_entropy_is_account_specific() { + assert_ne!(dpapi_entropy("host.example"), dpapi_entropy("identity:host.example")); + assert_eq!(dpapi_entropy("host.example"), dpapi_entropy("host.example")); + } + + #[cfg(windows)] + #[test] + fn dpapi_round_trips_and_rejects_foreign_entropy() { + let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0"; + let blob = crate::dpapi::protect(secret, &dpapi_entropy("identity:a.example")).unwrap(); + assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext"); + + let back = crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:a.example")).unwrap(); + assert_eq!(back, secret); + + // A blob moved to another account's slot must not decrypt. + assert!(crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:b.example")).is_err()); + } +} diff --git a/Client/tauri-client/src/components/AdminActions.ts b/Client/tauri-client/src/components/AdminActions.ts index 40c390c0..2a6b5985 100644 --- a/Client/tauri-client/src/components/AdminActions.ts +++ b/Client/tauri-client/src/components/AdminActions.ts @@ -15,7 +15,8 @@ export interface MemberContextMenuOptions { currentRole: string; availableRoles: readonly string[]; onKick(): Promise; - onBan(): Promise; + /** The reason is stored and displayed by the server; empty means "no reason given". */ + onBan(reason: string): Promise; onChangeRole(newRole: string): Promise; } @@ -51,26 +52,70 @@ function createSeparator(): HTMLDivElement { return createElement("div", { class: "context-menu__separator" }); } +/** How long a "Are you sure?" state stays armed before reverting. */ +const CONFIRM_TIMEOUT_MS = 4000; + +/** + * Two-click confirm with an in-flight state. + * + * The armed state auto-disarms after a few seconds so a menu left open doesn't + * turn a stray second click into a ban, and the item shows progress while the + * request is running — a slow kick used to look like nothing happened. + */ function withConfirmation( item: HTMLDivElement, confirmLabel: string, - onConfirm: () => void, + onConfirm: () => void | Promise, signal: AbortSignal, + pendingLabel = "Working...", ): void { let confirming = false; + let running = false; + let disarmTimer: ReturnType | null = null; const originalLabel = item.textContent ?? ""; + function disarm(): void { + confirming = false; + if (disarmTimer !== null) { + clearTimeout(disarmTimer); + disarmTimer = null; + } + setText(item, originalLabel); + } + + signal.addEventListener("abort", () => { + if (disarmTimer !== null) clearTimeout(disarmTimer); + }); + item.addEventListener( "click", (e) => { e.stopPropagation(); - if (confirming) { - confirming = false; - setText(item, originalLabel); - onConfirm(); - } else { + if (running) return; + if (!confirming) { confirming = true; setText(item, confirmLabel); + disarmTimer = setTimeout(disarm, CONFIRM_TIMEOUT_MS); + return; + } + if (disarmTimer !== null) { + clearTimeout(disarmTimer); + disarmTimer = null; + } + confirming = false; + running = true; + setText(item, pendingLabel); + item.classList.add("context-menu__item--pending"); + const done = (): void => { + running = false; + item.classList.remove("context-menu__item--pending"); + setText(item, originalLabel); + }; + const result = onConfirm(); + if (result instanceof Promise) { + void result.then(done, done); + } else { + done(); } }, { signal }, @@ -142,17 +187,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont }, "Kick", ); - withConfirmation( - kickItem, - "Are you sure?", - () => { - void options.onKick(); - }, - ac.signal, - ); + withConfirmation(kickItem, "Are you sure?", () => options.onKick(), ac.signal, "Kicking..."); menu.appendChild(kickItem); - // Ban with confirmation + // Ban — collects the reason the server stores and displays alongside the ban. const banItem = createElement( "div", { @@ -160,15 +198,74 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont }, "Ban", ); - withConfirmation( - banItem, - "Are you sure?", - () => { - void options.onBan(); - }, - ac.signal, + const banReasonRow = createElement("div", { + class: "context-menu__reason", + style: "display:none;padding:6px 8px", + }); + const banReasonInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "Reason (optional)", + maxlength: "200", + "data-testid": "ban-reason-input", + style: "width:100%;font-size:12px", + }); + const banConfirm = createElement( + "div", + { class: "context-menu__item context-menu__item--danger", "data-testid": "ban-confirm" }, + "Confirm Ban", ); - menu.appendChild(banItem); + appendChildren(banReasonRow, banReasonInput, banConfirm); + + banItem.addEventListener( + "click", + (e) => { + e.stopPropagation(); + banItem.style.display = "none"; + banReasonRow.style.display = ""; + banReasonInput.focus(); + }, + { signal: ac.signal }, + ); + + // Typing a reason must not close the menu or trigger the outside-click guard. + banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal: ac.signal }); + banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal: ac.signal }); + + let banRunning = false; + function submitBan(): void { + if (banRunning) return; + banRunning = true; + setText(banConfirm, "Banning..."); + banConfirm.classList.add("context-menu__item--pending"); + const done = (): void => { + banRunning = false; + banConfirm.classList.remove("context-menu__item--pending"); + setText(banConfirm, "Confirm Ban"); + }; + void options.onBan(banReasonInput.value.trim()).then(done, done); + } + + banConfirm.addEventListener( + "click", + (e) => { + e.stopPropagation(); + submitBan(); + }, + { signal: ac.signal }, + ); + banReasonInput.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + submitBan(); + } + }, + { signal: ac.signal }, + ); + + appendChildren(menu, banItem, banReasonRow); function destroy(): void { ac.abort(); @@ -214,14 +311,7 @@ export function createChannelContextMenu(options: ChannelContextMenuOptions): Co }, "Delete Channel", ); - withConfirmation( - deleteItem, - "Are you sure?", - () => { - void options.onDelete(); - }, - ac.signal, - ); + withConfirmation(deleteItem, "Are you sure?", () => options.onDelete(), ac.signal, "Deleting..."); menu.appendChild(deleteItem); function destroy(): void { diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index ce11477a..33e2ce79 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -562,11 +562,26 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC const unsubscribers: Array<() => void> = []; + /** Voice-user rows from the last render, keyed by user id — lets the + * speaking-only subscription patch classes without per-user querySelector. */ + const voiceRowByUserId = new Map(); + + function rebuildVoiceRowCache(): void { + voiceRowByUserId.clear(); + if (channelList === null) return; + for (const row of channelList.querySelectorAll( + ".voice-user-item[data-voice-uid]", + )) { + voiceRowByUserId.set(Number(row.dataset.voiceUid), row); + } + } + function renderChannels(): void { if (channelList === null) { return; } clearChildren(channelList); + voiceRowByUserId.clear(); const grouped = getChannelsByCategory(); const state = channelsStore.getState(); @@ -601,6 +616,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC ), ); } + + rebuildVoiceRowCache(); } function mount(container: Element): void { @@ -659,43 +676,44 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC ); unsubscribers.push(unsubConnStatus); - // Subscribe to voice store — only full re-render when users join/leave - // or mute/deafen/camera changes. Speaking state is patched in-place via - // CSS class toggle to avoid destroying DOM elements (which kills hover). - let prevVoiceStructureSig = ""; - const unsubVoice = voiceStore.subscribe((state) => { - // Structural signature: who is in which channel + mute/deafen/camera. - // Excludes speaking — that's patched in-place below. - let structSig = String(state.currentChannelId ?? ""); - for (const [chId, users] of state.voiceUsers) { - structSig += `|${chId}`; - for (const [uid, u] of users) { - // Include the E2EE verification status so a verified↔unverified↔mismatch - // flip re-renders the badge (it lives outside voiceUsers, in peerVerifications). - const verif = state.peerVerifications?.get(uid); - structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`; + // Subscribe to voice store, split in two: + // (a) a structural selector (who is in which channel + mute/deafen/camera/ + // screenshare + E2EE verification, excluding `speaking`) that does a + // full re-render; + // (b) a speaking-only patcher that toggles CSS classes on rows cached at + // render time, so a speaker event never destroys DOM elements (which + // kills hover) and never pays a per-user querySelector. + const unsubVoiceStructure = voiceStore.subscribeSelector( + (state) => { + let structSig = String(state.currentChannelId ?? ""); + for (const [chId, users] of state.voiceUsers) { + structSig += `|${chId}`; + for (const [uid, u] of users) { + // Include the E2EE verification status so a verified↔unverified↔mismatch + // flip re-renders the badge (it lives outside voiceUsers, in peerVerifications). + const verif = state.peerVerifications?.get(uid); + structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`; + } } - } - if (structSig !== prevVoiceStructureSig) { - prevVoiceStructureSig = structSig; - renderChannels(); - return; - } + return structSig; + }, + () => renderChannels(), + ); + unsubscribers.push(unsubVoiceStructure); - // Patch speaking state in-place — toggle CSS class without re-rendering. - if (channelList === null) return; + // Registered after the structural subscription so a structural change in + // the same notification re-renders (and refreshes the row cache) first. + const unsubSpeaking = voiceStore.subscribe((state) => { for (const [, users] of state.voiceUsers) { for (const [uid, u] of users) { - const row = channelList.querySelector( - `.voice-user-item[data-voice-uid="${uid}"]`, - ); - if (row !== null) { + const row = voiceRowByUserId.get(uid); + if (row !== undefined) { row.classList.toggle("speaking", u.speaking); } } } }); - unsubscribers.push(unsubVoice); + unsubscribers.push(unsubSpeaking); } function destroy(): void { @@ -705,6 +723,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC unsub(); } unsubscribers.length = 0; + voiceRowByUserId.clear(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/InviteManager.ts b/Client/tauri-client/src/components/InviteManager.ts index 17c270b0..94c83d7d 100644 --- a/Client/tauri-client/src/components/InviteManager.ts +++ b/Client/tauri-client/src/components/InviteManager.ts @@ -33,6 +33,9 @@ export interface InviteManagerOptions { // Helpers // --------------------------------------------------------------------------- +/** How long a "Sure?" revoke stays armed before reverting. */ +const CONFIRM_TIMEOUT_MS = 4000; + function maskCode(code: string): string { if (code.length <= 6) return code; return `${code.slice(0, 3)}...${code.slice(-3)}`; @@ -85,12 +88,43 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom { signal: ac.signal }, ); + // Revoking kills a live invite link — two-click confirm, then an + // in-flight state so a slow revoke isn't clicked twice. const revokeBtn = createElement("button", { class: "invite-item__revoke" }); + const revokeLabel = document.createTextNode(" Revoke"); revokeBtn.appendChild(createIcon("trash-2", 14)); - revokeBtn.appendChild(document.createTextNode(" Revoke")); + revokeBtn.appendChild(revokeLabel); + let confirming = false; + let revoking = false; + let disarmTimer: ReturnType | null = null; + const disarm = (): void => { + confirming = false; + if (disarmTimer !== null) { + clearTimeout(disarmTimer); + disarmTimer = null; + } + revokeLabel.nodeValue = " Revoke"; + revokeBtn.classList.remove("invite-item__revoke--confirming"); + }; revokeBtn.addEventListener( "click", () => { + if (revoking) return; + if (!confirming) { + confirming = true; + revokeLabel.nodeValue = " Sure?"; + revokeBtn.classList.add("invite-item__revoke--confirming"); + disarmTimer = setTimeout(disarm, CONFIRM_TIMEOUT_MS); + return; + } + if (disarmTimer !== null) { + clearTimeout(disarmTimer); + disarmTimer = null; + } + confirming = false; + revoking = true; + revokeBtn.disabled = true; + revokeLabel.nodeValue = " Revoking..."; void options .onRevokeInvite(invite.code) .then(() => { @@ -98,6 +132,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom renderList(); }) .catch(() => { + revoking = false; + revokeBtn.disabled = false; + revokeBtn.classList.remove("invite-item__revoke--confirming"); + revokeLabel.nodeValue = " Revoke"; options.onError?.("Failed to revoke invite"); }); }, @@ -142,17 +180,28 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom const footer = createElement("div", { class: "modal-footer" }); const createBtn = createElement("button", { class: "invite-manager__create btn-modal-save" }); createBtn.appendChild(createIcon("external-link", 14)); - createBtn.appendChild(document.createTextNode(" Create Invite")); + const createLabel = document.createTextNode(" Create Invite"); + createBtn.appendChild(createLabel); createBtn.addEventListener( "click", () => { + // Without this guard an impatient double-click mints two invites. + if (createBtn.disabled) return; + createBtn.disabled = true; + createLabel.nodeValue = " Creating..."; + const done = (): void => { + createBtn.disabled = false; + createLabel.nodeValue = " Create Invite"; + }; void options .onCreateInvite() .then((newInvite) => { invites = [...invites, newInvite]; renderList(); + done(); }) .catch(() => { + done(); options.onError?.("Failed to create invite"); }); }, diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts index b9b7bc1d..61cef581 100644 --- a/Client/tauri-client/src/components/MemberList.ts +++ b/Client/tauri-client/src/components/MemberList.ts @@ -7,8 +7,9 @@ import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import { Disposable } from "@lib/disposable"; -import { membersStore, type Member } from "@stores/members.store"; +import { membersStore, type Member, type MembersState } from "@stores/members.store"; import { authStore } from "@stores/auth.store"; +import { channelsStore } from "@stores/channels.store"; import { createMemberContextMenu } from "@components/AdminActions"; import type { UserStatus } from "@lib/types"; @@ -16,10 +17,25 @@ import type { UserStatus } from "@lib/types"; export interface MemberListOptions { readonly currentUserRole: string; readonly onKick: (userId: number, username: string) => Promise; - readonly onBan: (userId: number, username: string) => Promise; + readonly onBan: (userId: number, username: string, reason: string) => Promise; readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise; } +/** Roles offered in the "Change Role" submenu when the server hasn't sent any. */ +const FALLBACK_ASSIGNABLE_ROLES: readonly string[] = ["admin", "moderator", "member"]; + +/** + * Role names an admin can assign, taken from the server's role list. "owner" is + * excluded — ownership transfer isn't a context-menu action. + */ +function assignableRoleNames(): readonly string[] { + const roles = channelsStore + .getState() + .roles.map((r) => r.name.toLowerCase()) + .filter((name) => name !== "owner"); + return roles.length > 0 ? roles : FALLBACK_ASSIGNABLE_ROLES; +} + /** Ordered role groups with display names and CSS color variables. */ const ROLE_GROUPS: readonly { readonly role: string; @@ -127,7 +143,10 @@ function createMemberItem( closeActiveMenu(); document.removeEventListener("mousedown", handleOutsideClick); - const availableRoles = ["admin", "moderator", "member"]; + // Roles come from the server's `ready` payload — a hardcoded list made + // custom roles unreachable and, worse, unresolvable to a role id, so + // picking one silently did nothing. + const availableRoles = assignableRoleNames(); activeMenu = createMemberContextMenu({ userId: member.id, @@ -135,7 +154,7 @@ function createMemberItem( currentRole: member.role.toLowerCase(), availableRoles, onKick: () => opts.onKick(member.id, member.username), - onBan: () => opts.onBan(member.id, member.username), + onBan: (reason: string) => opts.onBan(member.id, member.username, reason), onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole), }); @@ -157,13 +176,18 @@ function createMemberItem( return item; } -function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: AbortSignal): void { +function renderList( + root: HTMLDivElement, + opts: MemberListOptions, + signal: AbortSignal, + rowsByUserId: Map, +): void { clearChildren(root); + rowsByUserId.clear(); const state = membersStore.getState(); - const allMembers = Array.from(state.members.values()); - if (allMembers.length === 0) { + if (state.members.size === 0) { const emptyState = createElement("div", { class: "member-list-empty" }); const msg = createElement("p", { class: "member-list-empty-text" }, "No members online"); emptyState.appendChild(msg); @@ -171,10 +195,23 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort return; } + // Single pass: bucket members by (lowercased) role, then sort each bucket + // by status \u2014 instead of one filter + toSorted sweep per role group. + const buckets = new Map(); + for (const member of state.members.values()) { + const role = member.role.toLowerCase(); + const bucket = buckets.get(role); + if (bucket === undefined) { + buckets.set(role, [member]); + } else { + bucket.push(member); + } + } + for (const group of ROLE_GROUPS) { - const groupMembers = allMembers - .filter((m) => m.role.toLowerCase() === group.role) - .toSorted((a, b) => statusPriority(a.status) - statusPriority(b.status)); + const groupMembers = (buckets.get(group.role) ?? []).toSorted( + (a, b) => statusPriority(a.status) - statusPriority(b.status), + ); if (groupMembers.length === 0) continue; @@ -186,7 +223,57 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort root.appendChild(header); for (const member of groupMembers) { - root.appendChild(createMemberItem(member, group.colorVar, opts, signal)); + const item = createMemberItem(member, group.colorVar, opts, signal); + rowsByUserId.set(member.id, item); + root.appendChild(item); + } + } +} + +/** True when the only difference between two member maps is presence status \u2014 + * same ids with identical username/role/avatar/identity key. Such updates can + * be patched into the existing rows instead of rebuilding the list. */ +function isPresenceOnlyChange( + prev: ReadonlyMap, + next: ReadonlyMap, +): boolean { + if (prev.size === 0 || prev.size !== next.size) return false; + for (const [id, member] of next) { + const before = prev.get(id); + if (before === undefined) return false; + if (before === member) continue; + if ( + before.username !== member.username || + before.role !== member.role || + before.avatar !== member.avatar || + before.identityPublicKey !== member.identityPublicKey + ) { + return false; + } + } + return true; +} + +/** Patch status dots/classes in place for members whose presence changed. + * Row identity (and therefore hover/context-menu state) is preserved; the + * status-priority sort order is deliberately not reshuffled until the next + * structural render. */ +function patchPresence( + prev: ReadonlyMap, + next: ReadonlyMap, + rowsByUserId: ReadonlyMap, +): void { + for (const [id, member] of next) { + const before = prev.get(id); + if (before === undefined || before.status === member.status) continue; + const row = rowsByUserId.get(id); + if (row === undefined) continue; + row.classList.toggle("offline", member.status === "offline"); + const dot = row.querySelector(".mi-status"); + if (dot !== null) { + dot.style.background = statusColor(member.status); + dot.setAttribute("aria-label", member.status); + dot.title = member.status; } } } @@ -194,18 +281,27 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort export function createMemberList(opts: MemberListOptions): MountableComponent { const disposable = new Disposable(); let root: HTMLDivElement | null = null; + /** Rendered rows by user id \u2014 lets presence-only updates patch in place. */ + const rowsByUserId = new Map(); + let prevMembers: ReadonlyMap = new Map(); function mount(container: Element): void { root = createElement("div", { class: "member-list", "data-testid": "member-list" }); - renderList(root, opts, disposable.signal); + prevMembers = membersStore.getState().members; + renderList(root, opts, disposable.signal, rowsByUserId); - disposable.onStoreChange( + disposable.onStoreChange>( membersStore, (s) => s.members, - () => { + (members) => { if (root !== null) { - renderList(root, opts, disposable.signal); + if (isPresenceOnlyChange(prevMembers, members)) { + patchPresence(prevMembers, members, rowsByUserId); + } else { + renderList(root, opts, disposable.signal, rowsByUserId); + } } + prevMembers = members; }, ); @@ -216,6 +312,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent { closeActiveMenu(); document.removeEventListener("mousedown", handleOutsideClick); disposable.destroy(); + rowsByUserId.clear(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index 75798d1c..adaa515c 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -42,6 +42,12 @@ export type MessageInputComponent = MountableComponent & { * the server would refuse is prevented here, not attempted and rejected. */ setDisabled(reason: string | null): void; + /** + * Open the attachment file picker, as the "+" button does. Backs the + * Ctrl+U shortcut. No-op while the composer is disabled or when the host + * didn't wire an upload handler. + */ + openFilePicker(): void; }; const TYPING_THROTTLE_MS = 3_000; @@ -86,6 +92,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo let gifUnavailable = options.gifApi === undefined; const controlButtons: HTMLButtonElement[] = []; let attachmentPreviewBar: HTMLDivElement | null = null; + /** Set by mount() when file uploads are wired; backs openFilePicker(). */ + let openPicker: (() => void) | null = null; /** Pending attachment IDs to send with the next message. */ const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = @@ -425,6 +433,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo { signal }, ); attachBtn.addEventListener("click", () => fileInput.click(), { signal }); + openPicker = () => { + if (disabledReason !== null) return; + fileInput.click(); + }; root?.appendChild(fileInput); } else { attachBtn.setAttribute("disabled", "true"); @@ -667,7 +679,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo replyText = null; editBar = null; attachmentPreviewBar = null; + openPicker = null; } - return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit, setDisabled }; + function openFilePicker(): void { + openPicker?.(); + } + + return { + mount, + destroy, + setReplyTo, + clearReply, + startEdit, + cancelEdit, + setDisabled, + openFilePicker, + }; } diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index cd08a67b..9cc69830 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -14,6 +14,7 @@ import { } from "@stores/messages.store"; import type { Message } from "@stores/messages.store"; import { membersStore } from "@stores/members.store"; +import { unobserveMedia } from "@lib/media-visibility"; const log = createLogger("message-list"); import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers"; @@ -100,10 +101,17 @@ function estimateItemHeight(item: VirtualItem): number { // -- Pre-process messages into virtual items ---------------------------------- -function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] { +/** Build virtual items for `messages`. The optional seed (`prevMsg` / + * `lastTimestamp`) lets the incremental tail-append path continue grouping and + * day-divider logic from an already-built item list. */ +function buildVirtualItems( + messages: readonly Message[], + seedPrevMsg: Message | null = null, + seedLastTimestamp: string | null = null, +): readonly VirtualItem[] { const items: VirtualItem[] = []; - let lastTimestamp: string | null = null; - let prevMsg: Message | null = null; + let lastTimestamp: string | null = seedLastTimestamp; + let prevMsg: Message | null = seedPrevMsg; for (const msg of messages) { if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) { @@ -319,6 +327,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } } + /** Release IntersectionObserver tracking, pending freeze timers, and frozen- + * frame data URLs for GIFs in rows that are about to be discarded — without + * this, media-visibility retains every ever rendered. Must run before + * every clearChildren(contentContainer) and on destroy. */ + function releaseTrackedMedia(): void { + if (contentContainer === null) return; + for (const img of contentContainer.querySelectorAll("img")) { + unobserveMedia(img); + } + } + let renderWindowCount = 0; let renderWindowResetTimer = 0; @@ -330,6 +349,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo const clientHeight = root.clientHeight; if (virtualItems.length === 0) { + releaseTrackedMedia(); clearChildren(contentContainer); // With no rows, the region shows the fetch state: an in-region loading // placeholder, an inline error + Retry, or the welcome/empty state once @@ -386,6 +406,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo renderedEnd = end; // Rebuild content + releaseTrackedMedia(); clearChildren(contentContainer); const fragment = document.createDocumentFragment(); for (let i = start; i < end; i++) { @@ -429,6 +450,95 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } } + // --------------------------------------------------------------------------- + // Incremental tail append (fast path) + // --------------------------------------------------------------------------- + + /** Cap on rendered rows for the append fast path. Once the window grows past + * this, fall back to renderAll so it is re-trimmed to the visible range. */ + const MAX_INCREMENTAL_WINDOW = 200; + + /** + * Fast path for the common "new message arrived at the tail" update: when + * the store's array is a pure suffix extension of `allMessages`, append the + * new rows and re-seed the Fenwick tree instead of tearing down the whole + * rendered window (renderAll → renderWindow REBUILD). Anything else (edits, + * deletes, history prepends, confirmations replacing optimistic rows) + * returns false so the caller does a full rebuild. + * + * Scroll-anchor/spacer safety: no existing row is touched, so the anchor + * item's offset only changes via the bottom spacer/appended rows below it; + * the ResizeObserver's RAF pass re-measures and restores the anchor exactly + * as it does for image loads. The renderWindow oscillation guard is not + * consumed — this path never rebuilds. + */ + function tryAppendMessages(): boolean { + if (root === null || contentContainer === null || tree === null) return false; + if (renderAllRunning || renderedStart < 0) return false; + + const prev = allMessages; + const next = getChannelMessages(options.channelId); + if (prev.length === 0 || next.length <= prev.length) return false; + for (let i = 0; i < prev.length; i++) { + if (next[i] !== prev[i]) return false; + } + + const prevLast = prev[prev.length - 1]!; + const appendedItems = buildVirtualItems(next.slice(prev.length), prevLast, prevLast.timestamp); + const oldItemCount = virtualItems.length; + const windowAtTail = renderedEnd === oldItemCount; + if ( + windowAtTail && + renderedEnd - renderedStart + appendedItems.length > MAX_INCREMENTAL_WINDOW + ) { + return false; // window has grown too large — let renderAll re-trim it + } + + const atBottom = isNearBottom(); + + // Capture measured heights of the currently rendered rows before swapping + // trees so the rebuilt tree starts from real measurements. + measureRendered(); + + allMessages = next; + virtualItems = [...virtualItems, ...appendedItems]; + + // Extend the height index. FenwickTree is fixed-size, so re-seed a fresh + // one from the height cache — cheap relative to the DOM teardown this + // path avoids. + tree = new FenwickTree(virtualItems.length); + for (let i = 0; i < virtualItems.length; i++) { + const cached = heightCache.get(itemKey(i)); + tree.set(i, cached !== undefined ? cached : estimateItemHeight(virtualItems[i]!)); + } + + if (windowAtTail) { + // The rendered window includes the old tail — append the new rows. + const fragment = document.createDocumentFragment(); + for (const item of appendedItems) { + if (item.kind === "divider") { + fragment.appendChild(renderDayDivider(item.timestamp)); + } else { + fragment.appendChild( + renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal), + ); + } + } + contentContainer.appendChild(fragment); + renderedEnd = virtualItems.length; + measureRendered(); + } + // Otherwise the user has scrolled up past the tail: the new items only + // grow the bottom spacer; renderWindow picks them up on the next rebuild. + + updateSpacers(); + if (atBottom) { + scrollToBottom(); + updateScrollToBottomBtn(); + } + return true; + } + // Guard against re-entrant renderAll calls (e.g. if a subscriber fires // during rendering). Also detects rapid-fire loops. let renderAllRunning = false; @@ -618,9 +728,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo unsubscribers.push( messagesStore.subscribeSelector( - (s) => s.messagesByChannel, + // Scoped to the mounted channel so updates to OTHER channels (their + // array references are unchanged) never trigger a re-render here. + (s) => s.messagesByChannel.get(options.channelId), () => { - renderAll(); + if (!tryAppendMessages()) { + renderAll(); + } }, ), ); @@ -637,14 +751,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo ); // Only re-render when member roles change, not on presence/typing updates. - // Extract a role-only map so shallowEqual ignores status changes. + // The store bumps roleRevision solely on membership/role mutations, so + // selecting the counter avoids rebuilding a role map per notification. unsubscribers.push( membersStore.subscribeSelector( - (s) => { - const roles = new Map(); - for (const [id, m] of s.members) roles.set(id, m.role); - return roles; - }, + (s) => s.roleRevision ?? 0, () => { renderAll(); }, @@ -681,6 +792,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo unsubscribers.length = 0; heightCache.clear(); tree = null; + releaseTrackedMedia(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index 336e7c00..4a924455 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -11,10 +11,6 @@ import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; import { uiStore } from "@stores/ui.store"; import { authStore } from "@stores/auth.store"; -import { loadPref, applyTheme, THEMES } from "./settings/helpers"; -import type { ThemeName } from "./settings/helpers"; -import { getActiveThemeName, restoreTheme } from "@lib/themes"; -import { syncOsMotionListener } from "@lib/os-motion"; import { buildAccountTab } from "./settings/AccountTab"; import { buildAppearanceTab } from "./settings/AppearanceTab"; import { buildNotificationsTab } from "./settings/NotificationsTab"; @@ -66,54 +62,6 @@ const TAB_ICONS: Record = { Logs: "scroll-text", }; -// --------------------------------------------------------------------------- -// Apply stored appearance (called at app startup) -// --------------------------------------------------------------------------- - -/** - * Apply stored appearance preferences (theme, font size, compact mode). - * Call at app startup so the UI doesn't flash default styles. - */ -export function applyStoredAppearance(): void { - const activeThemeName = getActiveThemeName(); - if (activeThemeName in THEMES) { - applyTheme(activeThemeName as ThemeName); - } else { - restoreTheme(); - } - try { - const rawAccent = localStorage.getItem("owncord:settings:accentColor"); - if (rawAccent !== null) { - const accent = JSON.parse(rawAccent); - if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) { - document.documentElement.style.setProperty("--accent", accent); - document.body.style.setProperty("--accent", accent); - } - } - } catch { - // Corrupted localStorage — keep the theme default accent. - } - document.documentElement.style.setProperty( - "--font-size", - `${loadPref("fontSize", 16)}px`, - ); - document.documentElement.classList.toggle( - "compact-mode", - loadPref("compactMode", false), - ); - document.documentElement.classList.toggle( - "reduced-motion", - loadPref("reducedMotion", false), - ); - document.documentElement.classList.toggle( - "high-contrast", - loadPref("highContrast", false), - ); - document.documentElement.classList.toggle("large-font", loadPref("largeFont", false)); - - syncOsMotionListener(loadPref("syncOsMotion", false)); -} - // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -127,8 +75,11 @@ export function createSettingsOverlay( 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; const tabButtons = new Map(); let unsubUi: (() => void) | null = null; + let unsubAuth: (() => void) | null = null; // Stateful tabs — create via factory for proper cleanup on tab switch const logsTab = createLogsTab(() => activeTab, ac.signal); @@ -158,12 +109,21 @@ export function createSettingsOverlay( contentArea.appendChild(pageTitle); const builder = TAB_BUILDERS[activeTab]; contentArea.appendChild(builder()); + contentLive = true; + } + + /** Release resources held by the tab currently on screen. */ + function cleanupActiveTab(): void { + if (activeTab === "Voice & Audio") voiceTab.cleanup(); + // The Logs tab keeps a live log listener pointed at its (now discarded) + // list element — drop it so it isn't re-rendering a detached tree. + if (activeTab === "Logs") logsTab.cleanup(); } function setActiveTab(tab: TabName): void { if (tab === activeTab) return; // Clean up stateful tabs when switching away - if (activeTab === "Voice & Audio") voiceTab.cleanup(); + cleanupActiveTab(); activeTab = tab; for (const [name, btn] of tabButtons) { btn.classList.toggle("active", name === tab); @@ -174,12 +134,17 @@ export function createSettingsOverlay( function show(): void { 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(); } function hide(): void { root?.classList.remove("open"); - // Stop camera preview and mic meter when settings overlay closes - voiceTab.cleanup(); + // Stop camera preview, mic meter, and the log listener when the overlay closes + cleanupActiveTab(); + contentLive = false; } // ---- MountableComponent --------------------------------------------------- @@ -220,6 +185,16 @@ export function createSettingsOverlay( appendChildren(profileSection, avatarEl, profileInfo); sidebar.appendChild(profileSection); + // Keep the sidebar identity in step with the store — renaming yourself on + // the Account tab used to leave the old name sitting here until restart. + unsubAuth = authStore.subscribeSelector( + (s) => s.user?.username, + (name) => { + profileName.textContent = name ?? "Unknown"; + avatarEl.textContent = (name ?? "U").charAt(0).toUpperCase(); + }, + ); + // "User Settings" category — only Account belongs here (hidden when not authenticated) if (authenticated) { const userSettingsCat = createElement("div", { class: "settings-cat" }, "User Settings"); @@ -320,6 +295,9 @@ export function createSettingsOverlay( root.appendChild(panel); renderActiveTab(); + // Content built while the panel is closed is only a placeholder: opening + // rebuilds it so the first view is as fresh as every later one. + contentLive = uiStore.getState().settingsOpen; // Subscribe to uiStore for open/close unsubUi = uiStore.subscribeSelector( @@ -347,6 +325,10 @@ export function createSettingsOverlay( unsubUi(); unsubUi = null; } + if (unsubAuth !== null) { + unsubAuth(); + unsubAuth = null; + } logsTab.cleanup(); voiceTab.cleanup(); tabButtons.clear(); diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts index 34c32c17..b60f13a4 100644 --- a/Client/tauri-client/src/components/UserBar.ts +++ b/Client/tauri-client/src/components/UserBar.ts @@ -11,6 +11,7 @@ import { authStore } from "@stores/auth.store"; import { openSettings, uiStore } from "@stores/ui.store"; import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker"; import type { UserStatus } from "@lib/types"; +import { loadUserStatus, onUserStatusChange, saveUserStatus } from "@lib/userStatus"; import type { WsClient } from "@lib/ws"; export interface UserBarOptions { @@ -82,8 +83,11 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { }; statusPicker = createStatusPicker({ - currentStatus: "online", + // Start from the stored selection, not a hardcoded "online" — otherwise + // this picker and the settings Account tab show different statuses. + currentStatus: loadUserStatus(), onStatusChange: (status: UserStatus) => { + saveUserStatus(status); const ws = options?.ws; if (ws !== null && ws !== undefined && canSetStatus()) { ws.send({ type: "presence_update", payload: { status } } as never); @@ -92,6 +96,13 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { }); statusPicker.mount(statusPickerWrap); + // Reflect status changes made on the settings Account tab. + disposable.addCleanup( + onUserStatusChange((status) => statusPicker?.setStatus(status), { + signal: disposable.signal, + }), + ); + // Disable picker (with a reason) when the connection is down const updatePickerDisabled = (): void => { const enabled = canSetStatus(); diff --git a/Client/tauri-client/src/components/VoiceChannel.ts b/Client/tauri-client/src/components/VoiceChannel.ts deleted file mode 100644 index ac3d902e..00000000 --- a/Client/tauri-client/src/components/VoiceChannel.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * VoiceChannel component — renders a voice channel item with connected users. - * Returns an HTMLDivElement (not a MountableComponent). - * Step 6.51 - */ - -import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; -import { createIcon } from "@lib/icons"; -import { voiceStore } from "@stores/voice.store"; -import type { VoiceUser } from "@stores/voice.store"; -import { membersStore } from "@stores/members.store"; -import { setUserVolume, getUserVolume } from "@lib/livekitSession"; -import { authStore } from "@stores/auth.store"; -import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview"; -import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants"; - -export interface VoiceChannelOptions { - channelId: number; - channelName: string; - onJoin(): void; - onClickWatch?(tileId: number): void; -} - -export interface VoiceChannelResult { - element: HTMLDivElement; - update(): void; - destroy(): void; -} - -const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"]; - -function pickAvatarColor(username: string): string { - let hash = 0; - for (let i = 0; i < username.length; i++) { - hash = (hash * 31 + username.charCodeAt(i)) | 0; - } - return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2"; -} - -export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelResult { - const ac = new AbortController(); - const unsubs: Array<() => void> = []; - - // Wrapper div to hold the channel-item and voice-users-list as siblings - const root = createElement("div"); - - // Channel item row (same structure as text channels) - const channelItem = createElement("div", { class: "channel-item voice" }); - const icon = createElement("span", { class: "ch-icon" }); - icon.appendChild(createIcon("volume-2", 16)); - const nameEl = createElement("span", { class: "ch-name" }, options.channelName); - appendChildren(channelItem, icon, nameEl); - - // Users container - const usersContainer = createElement("div", { class: "voice-users-list" }); - - appendChildren(root, channelItem, usersContainer); - - // BUG-104: Attach scroll collapse once (not per-update) to avoid listener accumulation. - attachScrollCollapse(usersContainer, ac.signal); - - // Click to join - channelItem.addEventListener("click", options.onJoin, { signal: ac.signal }); - - // Track active context menu for cleanup - let activeCtxMenu: HTMLDivElement | null = null; - let menuDismissAc: AbortController | null = null; - - function closeContextMenu(): void { - if (menuDismissAc !== null) { - menuDismissAc.abort(); - menuDismissAc = null; - } - if (activeCtxMenu !== null) { - activeCtxMenu.remove(); - activeCtxMenu = null; - } - } - - function showVolumeMenu(userId: number, username: string, x: number, y: number): void { - closeContextMenu(); - - const menu = createElement("div", { class: "context-menu" }); - - // Header - const header = createElement( - "div", - { - class: "context-menu-item", - style: "font-weight:600;cursor:default;pointer-events:none", - }, - username, - ); - menu.appendChild(header); - - const sep = createElement("div", { class: "context-menu-sep" }); - menu.appendChild(sep); - - // Volume label - const currentVol = getUserVolume(userId); - const volLabel = createElement( - "div", - { - class: "context-menu-item", - style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none", - }, - `User Volume: ${currentVol}%`, - ); - menu.appendChild(volLabel); - - // Volume slider (0-200%, like Discord) - const sliderRow = createElement("div", { - style: "padding:4px 10px;display:flex;align-items:center;gap:8px", - }); - const slider = createElement("input", { - type: "range", - class: "settings-slider", - min: "0", - max: "200", - value: String(currentVol), - style: "flex:1", - }); - const valLabel = createElement( - "span", - { - class: "slider-val", - style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)", - }, - `${currentVol}%`, - ); - - slider.addEventListener("input", () => { - const val = Number(slider.value); - setText(valLabel, `${val}%`); - setText(volLabel, `User Volume: ${val}%`); - setUserVolume(userId, val); - }); - - appendChildren(sliderRow, slider, valLabel); - menu.appendChild(sliderRow); - - // Reset button - const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume"); - resetBtn.addEventListener("click", () => { - setUserVolume(userId, 100); - slider.value = "100"; - setText(valLabel, "100%"); - setText(volLabel, "User Volume: 100%"); - }); - menu.appendChild(resetBtn); - - // Position and show - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - document.body.appendChild(menu); - activeCtxMenu = menu; - - // Close on click outside — uses AbortController so cleanup on destroy works - menuDismissAc = new AbortController(); - const dismissSignal = menuDismissAc.signal; - setTimeout(() => { - if (dismissSignal.aborted) return; - document.addEventListener( - "mousedown", - (e: MouseEvent) => { - if (!menu.contains(e.target as Node)) { - closeContextMenu(); - } - }, - { signal: dismissSignal }, - ); - }, 0); - } - - function createUserRow(user: VoiceUser, username: string): HTMLDivElement { - const classes = user.speaking ? "voice-user-item speaking" : "voice-user-item"; - const row = createElement("div", { class: classes }); - - const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?"; - const color = pickAvatarColor(username); - const avatar = createElement("div", { class: "vu-avatar" }, initial); - avatar.style.background = color; - row.appendChild(avatar); - - const name = createElement("span", { class: "vu-name" }, username); - row.appendChild(name); - - if (user.camera) { - const cameraEl = createElement("span", { class: "vu-status" }); - cameraEl.appendChild(createIcon("camera", 14)); - row.appendChild(cameraEl); - } - - if (user.muted || user.deafened) { - const mutedEl = createElement("span", { class: "vu-muted" }); - mutedEl.appendChild(createIcon(user.deafened ? "headphones-off" : "mic-off", 14)); - row.appendChild(mutedEl); - } - - // Right-click for per-user volume (skip for own user) - const currentUser = authStore.getState().user; - if (currentUser === null || currentUser.id !== user.userId) { - row.addEventListener( - "contextmenu", - (e) => { - e.preventDefault(); - e.stopPropagation(); - showVolumeMenu(user.userId, username, e.clientX, e.clientY); - }, - { signal: ac.signal }, - ); - } - - return row; - } - - // Track previous Map reference to skip unnecessary re-renders - let prevChannelUsers: ReadonlyMap | undefined; - let prevMembers: ReadonlyMap | undefined; - - function update(): void { - const channelUsers = voiceStore.getState().voiceUsers.get(options.channelId); - const members = membersStore.getState().members; - - // Skip re-render if neither the channel's user map nor members changed - if (channelUsers === prevChannelUsers && members === prevMembers) return; - prevChannelUsers = channelUsers; - prevMembers = members; - - clearChildren(usersContainer); - - if (channelUsers === undefined) { - channelItem.classList.remove("active"); - return; - } - - for (const user of channelUsers.values()) { - const member = members.get(user.userId); - const username = (member as { username?: string } | undefined)?.username ?? "Unknown"; - const row = createUserRow(user, username); - usersContainer.appendChild(row); - - // Attach stream preview for remote users with active video - const currentUser = authStore.getState().user; - if ( - (currentUser === null || currentUser.id !== user.userId) && - (user.camera || user.screenshare) - ) { - const tileId = user.screenshare ? user.userId + SCREENSHARE_TILE_ID_OFFSET : user.userId; - attachStreamPreview( - row, - user.userId, - username, - user.screenshare, - user.camera, - ac.signal, - () => { - // Only join if not already in this channel - if (voiceStore.getState().currentChannelId !== options.channelId) { - options.onJoin(); - } - if (options.onClickWatch !== undefined) options.onClickWatch(tileId); - }, - options.onClickWatch !== undefined ? () => options.onClickWatch!(tileId) : undefined, - ); - } - } - - // Mark channel-item active if there are users - if (channelUsers.size > 0) { - channelItem.classList.add("active"); - } else { - channelItem.classList.remove("active"); - } - } - - // Initial render and subscribe - update(); - unsubs.push( - voiceStore.subscribeSelector( - (s) => s.voiceUsers, - () => update(), - ), - ); - unsubs.push( - membersStore.subscribeSelector( - (s) => s.members, - () => update(), - ), - ); - - function destroy(): void { - closeContextMenu(); - ac.abort(); - for (const unsub of unsubs) { - unsub(); - } - unsubs.length = 0; - } - - return { element: root, update, destroy }; -} diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts index d7569de1..c2e41a7b 100644 --- a/Client/tauri-client/src/components/message-list/attachments.ts +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -17,6 +17,15 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import type { Attachment } from "@lib/types"; import { openImageLightbox } from "./media"; +/** Cached value of the animateGifs preference. Invalidated on pref change + * (same pattern as roleColors in formatting.ts). */ +let animateGifsPref = loadPref("animateGifs", true); +window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => { + if (e.detail.key === "animateGifs") { + animateGifsPref = loadPref("animateGifs", true); + } +}) as EventListener); + // -- Server host state -------------------------------------------------------- /** Module-level server host for resolving relative attachment URLs. */ @@ -336,7 +345,7 @@ export function renderAttachment(att: Attachment): HTMLDivElement { "load", () => { clearReservation(); - if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true)); + if (isGif) observeMedia(img, cached, wrap, !animateGifsPref); }, { once: true }, ); @@ -357,7 +366,7 @@ export function renderAttachment(att: Attachment): HTMLDivElement { "load", () => { clearReservation(); - if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true)); + if (isGif) observeMedia(img, dataUrl, wrap, !animateGifsPref); }, { once: true }, ); diff --git a/Client/tauri-client/src/components/message-list/formatting.ts b/Client/tauri-client/src/components/message-list/formatting.ts index 00aa8616..bf2ac931 100644 --- a/Client/tauri-client/src/components/message-list/formatting.ts +++ b/Client/tauri-client/src/components/message-list/formatting.ts @@ -13,28 +13,59 @@ export const GROUP_THRESHOLD_MS = 5 * 60 * 1000; // -- Timestamp helpers -------------------------------------------------------- +/** Memoized epoch millis per raw timestamp string. Timestamps are immutable, + * and buildVirtualItems re-parses each one several times per render — the + * memo removes thousands of Date constructions + regex runs. Bounded FIFO. */ +const parsedTimestampCache = new Map(); +const PARSED_TIMESTAMP_CACHE_MAX = 2000; + /** Parse a timestamp string, appending 'Z' if no timezone info is present * so that UTC timestamps from SQLite are correctly interpreted. */ export function parseTimestamp(raw: string): Date { + const cached = parsedTimestampCache.get(raw); + if (cached !== undefined) return new Date(cached); + // SQLite datetime('now') produces "2026-03-19 08:29:41" (UTC, no suffix). // If there's no Z, +, or T with offset, treat as UTC by appending Z. - if (!raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)) { - return new Date(raw.replace(" ", "T") + "Z"); + const date = + !raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw) + ? new Date(raw.replace(" ", "T") + "Z") + : new Date(raw); + + const ms = date.getTime(); + if (!Number.isNaN(ms)) { + if (parsedTimestampCache.size >= PARSED_TIMESTAMP_CACHE_MAX) { + // Evict oldest entry (first inserted key) + const firstKey = parsedTimestampCache.keys().next().value; + if (firstKey !== undefined) parsedTimestampCache.delete(firstKey); + } + parsedTimestampCache.set(raw, ms); } - return new Date(raw); + return date; } +// Cached formatters — Intl.DateTimeFormat construction is expensive and these +// run for every rendered message. Only the FORMATTER is cached, never a +// formatted string: "Today"/"Yesterday" flips at midnight, so strings are +// recomputed per call from the cached formatter. +const FULL_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", +}); +const CLOCK_TIME_FORMAT = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, +}); + export function formatTime(iso: string): string { const d = parseTimestamp(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } export function formatFullDate(iso: string): string { - return parseTimestamp(iso).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + return FULL_DATE_FORMAT.format(parseTimestamp(iso)); } /** Discord-style relative timestamp: "Today at 2:34 PM", "Yesterday at 2:34 PM", @@ -43,11 +74,7 @@ export function formatMessageTimestamp(iso: string): string { const date = parseTimestamp(iso); const now = new Date(); - const timeStr = date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); + const timeStr = CLOCK_TIME_FORMAT.format(date); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayStart = new Date(todayStart.getTime() - 86_400_000); diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts index 351ae20b..d4246769 100644 --- a/Client/tauri-client/src/components/message-list/media.ts +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -16,6 +16,30 @@ import { renderGenericLinkPreview } from "./embeds"; const log = createLogger("media"); +// Cached embed/media preferences — read once and invalidated on pref change +// instead of hitting localStorage for every rendered message (same pattern as +// roleColors in formatting.ts and developerMode in renderers.ts). +let showEmbedsPref = loadPref("showEmbeds", true); +let inlineMediaPref = loadPref("inlineMedia", true); +let showLinkPreviewsPref = loadPref("showLinkPreviews", true); +let animateGifsPref = loadPref("animateGifs", true); +window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => { + switch (e.detail.key) { + case "showEmbeds": + showEmbedsPref = loadPref("showEmbeds", true); + break; + case "inlineMedia": + inlineMediaPref = loadPref("inlineMedia", true); + break; + case "showLinkPreviews": + showLinkPreviewsPref = loadPref("showLinkPreviews", true); + break; + case "animateGifs": + animateGifsPref = loadPref("animateGifs", true); + break; + } +}) as EventListener); + /** * Cache of rendered image heights keyed by URL. When virtual scroll rebuilds * DOM elements, new images use the cached height as min-height instead of the @@ -255,7 +279,7 @@ export function renderInlineImage(url: string): HTMLDivElement { img.addEventListener( "load", () => { - log.info("Image loaded", { + log.debug("Image loaded", { url: url.slice(0, 80), naturalW: img.naturalWidth, naturalH: img.naturalHeight, @@ -286,10 +310,7 @@ export function renderInlineImage(url: string): HTMLDivElement { img.addEventListener( "load", () => { - log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) }); - const startFrozen = !loadPref("animateGifs", true); - observeMedia(img, url, wrap, startFrozen); - log.debug("observeMedia complete", { startFrozen }); + observeMedia(img, url, wrap, !animateGifsPref); }, { once: true }, ); @@ -483,14 +504,8 @@ export function extractUrls(content: string): string[] { export function renderUrlEmbeds(content: string): DocumentFragment { const fragment = document.createDocumentFragment(); const urls = extractUrls(content); - log.debug("renderUrlEmbeds", { urlCount: urls.length, urls }); const seen = new Set(); - // Read preferences once before the loop to avoid per-URL localStorage reads - const showEmbeds = loadPref("showEmbeds", true); - const inlineMedia = loadPref("inlineMedia", true); - const showLinkPreviews = loadPref("showLinkPreviews", true); - for (const url of urls) { if (seen.has(url)) continue; seen.add(url); @@ -498,7 +513,7 @@ export function renderUrlEmbeds(content: string): DocumentFragment { // YouTube embed const ytId = extractYouTubeId(url); if (ytId !== null) { - if (!showEmbeds) continue; + if (!showEmbedsPref) continue; fragment.appendChild(renderYouTubeEmbed(ytId, url)); continue; } @@ -506,26 +521,18 @@ export function renderUrlEmbeds(content: string): DocumentFragment { // Direct image/GIF URL — render inline const isDirect = isDirectImageUrl(url); const isSafe = isSafeUrl(url); - log.debug("URL classification", { - url: url.slice(0, 80), - isDirect, - isSafe, - isGif: isGifUrl(url), - }); if (isDirect && isSafe) { - if (!inlineMedia) continue; + if (!inlineMediaPref) continue; fragment.appendChild(renderInlineImage(url)); continue; } // Generic URL preview (compact link card) if (isSafe) { - if (!showLinkPreviews) continue; - log.debug("Falling through to generic link preview", { url: url.slice(0, 80) }); + if (!showLinkPreviewsPref) continue; fragment.appendChild(renderGenericLinkPreview(url)); } } - log.debug("renderUrlEmbeds complete"); return fragment; } diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index e638aa1c..3f0cab7b 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -1,12 +1,15 @@ /** - * Message rendering barrel — re-exports all rendering helpers and contains - * the composite functions (renderMessage, renderDayDivider, renderReplyRef, - * renderSystemMessage) that orchestrate pieces from the split modules. + * Message rendering barrel — re-exports the rendering helpers consumers use + * and contains the composite functions (renderMessage, renderDayDivider, + * renderReplyRef, renderSystemMessage) that orchestrate pieces from the + * split modules. */ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; -import { loadPref } from "@components/settings/helpers"; +import { loadPref } from "@lib/preferences"; +import { canManageMessages } from "@lib/permissions"; +import { showToast } from "@lib/toast"; import type { Message } from "@stores/messages.store"; import type { MessageListOptions } from "../MessageList"; @@ -18,11 +21,11 @@ window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }> } }) as EventListener); -// -- Re-exports (preserve all existing public API) ---------------------------- +// -- Re-exports (only the names consumers actually import; everything else is +// -- available directly from the split modules) ------------------------------- export { GROUP_THRESHOLD_MS, - parseTimestamp, formatTime, formatFullDate, formatMessageTimestamp, @@ -33,42 +36,13 @@ export { } from "./formatting"; export { - MENTION_REGEX, - CODE_BLOCK_REGEX, - INLINE_CODE_REGEX, - URL_REGEX, renderInlineContent, renderMentions, renderMentionSegment, renderMessageContent, } from "./content-parser"; -export { - extractYouTubeId, - renderYouTubeEmbed, - isDirectImageUrl, - renderInlineImage, - openImageLightbox, - extractUrls, - renderUrlEmbeds, -} from "./media"; - -export type { OgMeta } from "./embeds"; -export { parseOgTags, renderGenericLinkPreview, applyOgMeta } from "./embeds"; - -export { - formatFileSize, - isImageMime, - isSafeUrl, - openCacheDb, - uint8ToBase64, - fetchImageAsDataUrl, - renderAttachment, - setServerHost, - resolveServerUrl, -} from "./attachments"; - -export { renderReactions } from "./reactions"; +export { setServerHost } from "./attachments"; // -- Imports for composite functions ------------------------------------------ @@ -307,7 +281,8 @@ export function renderMessage( actionsBar.appendChild(editBtn); } - if (msg.user.id === opts.currentUserId) { + // Own message, or a moderator acting on someone else's. + if (msg.user.id === opts.currentUserId || canManageMessages()) { const deleteBtn = createElement("button", { "data-testid": `msg-delete-${msg.id}`, "aria-label": "Delete", @@ -328,9 +303,12 @@ export function renderMessage( copyIdBtn.addEventListener( "click", () => { - void navigator.clipboard.writeText(String(msg.id)).catch(() => { - /* clipboard unavailable */ - }); + // No silent success: a copy with no feedback is indistinguishable + // from a clipboard that refused. + void navigator.clipboard.writeText(String(msg.id)).then( + () => showToast("Message ID copied", "success"), + () => showToast("Couldn't copy the message ID", "error"), + ); }, { signal }, ); diff --git a/Client/tauri-client/src/components/settings/AccountTab.ts b/Client/tauri-client/src/components/settings/AccountTab.ts index 0a9f149d..0a7957b1 100644 --- a/Client/tauri-client/src/components/settings/AccountTab.ts +++ b/Client/tauri-client/src/components/settings/AccountTab.ts @@ -7,8 +7,8 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import type { UserStatus } from "@lib/types"; import { authStore } from "@stores/auth.store"; +import { loadUserStatus, saveUserStatus } from "@lib/userStatus"; import type { SettingsOverlayOptions } from "../SettingsOverlay"; -import { loadPref, savePref } from "./helpers"; // --------------------------------------------------------------------------- // Types @@ -110,6 +110,11 @@ function buildPasswordSection( const newVal = newPw.value; const confirmVal = confirmPw.value; + pwError.style.color = "var(--red)"; + if (oldVal.length === 0) { + setText(pwError, "Enter your current password."); + return; + } if (newVal.length < 8) { setText(pwError, "New password must be at least 8 characters."); return; @@ -119,6 +124,14 @@ function buildPasswordSection( return; } setText(pwError, ""); + // In-flight state: a second click would burn an attempt against the + // server's lockout counter with the same credentials. + pwBtn.disabled = true; + setText(pwBtn, "Changing..."); + const finish = (): void => { + pwBtn.disabled = false; + setText(pwBtn, "Change Password"); + }; void options .onChangePassword(oldVal, newVal) .then(() => { @@ -133,9 +146,11 @@ function buildPasswordSection( pwError.style.color = "var(--red)"; pwSuccessTimer = null; }, 3000); + finish(); }) .catch((err: unknown) => { setText(pwError, err instanceof Error ? err.message : "Failed to change password."); + finish(); }); }, { signal }, @@ -273,24 +288,56 @@ function buildTotpConfirmArea( const elements: HTMLElement[] = [qrLabel, qrUri]; if (result.backup_codes.length > 0) { + // These codes are shown exactly once — the confirm step replaces this view. + // Say so, and give a one-click way to keep them. const backupLabel = createElement( "div", { - style: "color:var(--text-muted);font-size:13px;margin-bottom:8px", + style: "color:var(--yellow, #faa61a);font-size:13px;margin-bottom:8px;font-weight:600", }, - "Save these backup codes in a safe place:", + "Save these backup codes now — you won't see them again:", ); + const codesText = result.backup_codes.join("\n"); const backupList = createElement( "code", { style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" + - "font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" + + "font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:8px;" + "color:var(--text-primary);user-select:all", + "data-testid": "totp-backup-codes", }, - result.backup_codes.join("\n"), + codesText, ); - elements.push(backupLabel, backupList); + const copyBtn = createElement( + "button", + { + class: "ac-btn", + style: "margin-bottom:12px", + "data-testid": "totp-copy-backup-codes", + }, + "Copy Codes", + ); + let copyResetTimer: ReturnType | null = null; + copyBtn.addEventListener( + "click", + () => { + const restore = (label: string): void => { + setText(copyBtn, label); + if (copyResetTimer !== null) clearTimeout(copyResetTimer); + copyResetTimer = setTimeout(() => { + setText(copyBtn, "Copy Codes"); + copyResetTimer = null; + }, 1500); + }; + void navigator.clipboard + .writeText(codesText) + .then(() => restore("Copied!")) + .catch(() => restore("Copy failed")); + }, + { signal }, + ); + elements.push(backupLabel, backupList, copyBtn); } const codeInput = createElement("input", { @@ -546,7 +593,7 @@ function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSigna const sectionTitle = createElement("div", { class: "settings-section-title" }, "Status"); const optionsList = createElement("div", { class: "settings-status-options" }); - const currentStatus = loadPref("userStatus", "online"); + const currentStatus = loadUserStatus(); const rowElements = new Map(); for (const opt of STATUS_OPTIONS) { @@ -578,7 +625,7 @@ function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSigna } row.classList.add("active"); row.setAttribute("aria-pressed", "true"); - savePref("userStatus", opt.value); + saveUserStatus(opt.value); options.onStatusChange(opt.value); }; diff --git a/Client/tauri-client/src/components/settings/AdvancedTab.ts b/Client/tauri-client/src/components/settings/AdvancedTab.ts index f35542de..9ff49cc9 100644 --- a/Client/tauri-client/src/components/settings/AdvancedTab.ts +++ b/Client/tauri-client/src/components/settings/AdvancedTab.ts @@ -22,6 +22,11 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { // ---- Toggles --------------------------------------------------------------- + // NOTE: a "Hardware Acceleration" toggle used to sit here. Nothing read the + // preference it wrote — GPU compositing is decided by the webview before any + // JavaScript runs — so it was a switch that did nothing. Re-adding it means + // persisting the choice where the Rust startup path can read it before the + // webview is created; until then the panel doesn't claim the capability. const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [ { key: "developerMode", @@ -29,12 +34,6 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { desc: "Show message IDs, user IDs, and channel IDs on context menus", fallback: false, }, - { - key: "hardwareAcceleration", - label: "Hardware Acceleration", - desc: "Use GPU for rendering. Requires restart to take effect", - fallback: true, - }, ]; for (const item of toggles) { diff --git a/Client/tauri-client/src/components/settings/AppearanceTab.ts b/Client/tauri-client/src/components/settings/AppearanceTab.ts index 5b20da74..cd57aff4 100644 --- a/Client/tauri-client/src/components/settings/AppearanceTab.ts +++ b/Client/tauri-client/src/components/settings/AppearanceTab.ts @@ -54,7 +54,13 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement { } btn.classList.add("active"); btn.setAttribute("aria-checked", "true"); - if (!hasStoredAccent) { + if (hasStoredAccent) { + // applyThemeByName clears every inline custom property on , + // which includes the accent override applyAccent puts there. Without + // re-applying it, a theme that sets --accent on its body class + // (neon-glow) silently reverts the user's accent until restart. + applyAccent(loadPref("accentColor", getDefaultAccent(name))); + } else { syncDisplayedAccent(getDefaultAccent(name)); } }; diff --git a/Client/tauri-client/src/components/settings/KeybindsTab.ts b/Client/tauri-client/src/components/settings/KeybindsTab.ts index 213b5930..8184000c 100644 --- a/Client/tauri-client/src/components/settings/KeybindsTab.ts +++ b/Client/tauri-client/src/components/settings/KeybindsTab.ts @@ -111,8 +111,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { const navBinds: [string, string][] = [ ["Quick Switcher", "Ctrl + K"], - ["Mark as Read", "Escape"], ["Search Messages", "Ctrl + F"], + ["Close Overlay / Cancel", "Escape"], ]; for (const [label, shortcut] of navBinds) { const row = createElement("div", { class: "keybind-row" }); @@ -151,6 +151,16 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { section.appendChild(row); } + section.appendChild( + createElement( + "div", + { + style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 0 0; line-height: 1.4;", + }, + "Voice shortcuts apply while you are connected to a voice channel.", + ), + ); + // ── Messages section ─────────────────────────────────────── section.appendChild(createElement("div", { class: "settings-separator" })); diff --git a/Client/tauri-client/src/components/settings/LogsTab.ts b/Client/tauri-client/src/components/settings/LogsTab.ts index b5f166b9..0ecb171e 100644 --- a/Client/tauri-client/src/components/settings/LogsTab.ts +++ b/Client/tauri-client/src/components/settings/LogsTab.ts @@ -3,11 +3,17 @@ */ import { createElement, appendChildren, clearChildren } from "@lib/dom"; -import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger"; +import { + getLogBuffer, + clearLogBuffer, + addLogListener, + setLogLevel, + getLogLevel, +} from "@lib/logger"; import type { LogEntry, LogLevel } from "@lib/logger"; import type { TabName } from "../SettingsOverlay"; import { getSessionDebugInfo } from "@lib/livekitSession"; -import { savePref } from "./helpers"; +import { savePref, readMigratedStringPref } from "./helpers"; // --------------------------------------------------------------------------- // Constants @@ -61,40 +67,6 @@ function formatLogEntry(entry: LogEntry): HTMLDivElement { return row; } -function readMigratedStringPref( - key: string, - fallback: T, - allowedValues: readonly T[], -): T { - const currentRaw = localStorage.getItem(`owncord:settings:${key}`); - if (currentRaw !== null) { - try { - const currentValue: unknown = JSON.parse(currentRaw); - if (typeof currentValue === "string" && allowedValues.includes(currentValue as T)) { - return currentValue as T; - } - } catch { - // Ignore corrupted current storage and fall back below. - } - } - - const legacyRaw = localStorage.getItem(key); - if (legacyRaw !== null) { - let legacyValue: unknown = legacyRaw; - try { - legacyValue = JSON.parse(legacyRaw); - } catch { - // Legacy values were previously stored as raw strings. - } - if (typeof legacyValue === "string" && allowedValues.includes(legacyValue as T)) { - savePref(key, legacyValue); - return legacyValue as T; - } - } - - return fallback; -} - // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -201,6 +173,12 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): if (savedMinLevel !== "") { levelSelect.value = savedMinLevel; setLogLevel(savedMinLevel); + } else { + // No saved pref: reflect the actual effective runtime level (the + // applyStoredLogLevel fallback — info in prod, debug in dev) instead of + // leaving the select on its first option (DEBUG). Purely cosmetic — no + // save/apply, so the runtime level is unchanged. + levelSelect.value = getLogLevel(); } levelSelect.addEventListener( "change", diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index 72147633..558387bd 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -332,46 +332,42 @@ function buildVoiceAudioTabInner( previewWrap.appendChild(previewVideo); section.appendChild(previewWrap); - // Populate devices asynchronously - void (async () => { + /** + * (Re)fill the three device dropdowns from the current device list. + * + * Called on build and again on every `devicechange`, so unplugging a headset + * with the panel open removes it from the list instead of leaving a dead + * entry the user can select. A saved device that has vanished falls back to + * "Default" — the same thing the voice session does on hot-swap. + */ + async function populateDevices(): Promise { + const selects: Array<[HTMLSelectElement, MediaDeviceKind, string, string]> = [ + [inputSelect, "audioinput", "audioInputDevice", "Microphone"], + [outputSelect, "audiooutput", "audioOutputDevice", "Speaker"], + [videoSelect, "videoinput", "videoInputDevice", "Camera"], + ]; try { const devices = await navigator.mediaDevices.enumerateDevices(); - const savedInput = loadPref("audioInputDevice", ""); - const savedOutput = loadPref("audioOutputDevice", ""); - const savedVideo = loadPref("videoInputDevice", ""); + if (signal.aborted) return; - for (const d of devices) { - if (d.kind === "audioinput") { - const opt = createElement( - "option", - { value: d.deviceId }, - d.label || `Microphone (${d.deviceId.slice(0, 8)})`, + for (const [select, kind, prefKey, label] of selects) { + const saved = loadPref(prefKey, ""); + // Keep the leading "Default" option, replace the rest. + while (select.options.length > 1) select.remove(1); + let savedStillPresent = false; + for (const d of devices) { + if (d.kind !== kind) continue; + if (d.deviceId === saved) savedStillPresent = true; + select.appendChild( + createElement( + "option", + { value: d.deviceId }, + d.label || `${label} (${d.deviceId.slice(0, 8)})`, + ), ); - if (d.deviceId === savedInput) opt.setAttribute("selected", ""); - inputSelect.appendChild(opt); - } else if (d.kind === "audiooutput") { - const opt = createElement( - "option", - { value: d.deviceId }, - d.label || `Speaker (${d.deviceId.slice(0, 8)})`, - ); - if (d.deviceId === savedOutput) opt.setAttribute("selected", ""); - outputSelect.appendChild(opt); - } else if (d.kind === "videoinput") { - const opt = createElement( - "option", - { value: d.deviceId }, - d.label || `Camera (${d.deviceId.slice(0, 8)})`, - ); - if (d.deviceId === savedVideo) opt.setAttribute("selected", ""); - videoSelect.appendChild(opt); } + select.value = saved !== "" && savedStillPresent ? saved : ""; } - - // Restore saved selections - if (savedInput) inputSelect.value = savedInput; - if (savedOutput) outputSelect.value = savedOutput; - if (savedVideo) videoSelect.value = savedVideo; } catch { const errOpt = createElement( "option", @@ -380,7 +376,22 @@ function buildVoiceAudioTabInner( ); inputSelect.appendChild(errOpt); } - })(); + } + + void populateDevices(); + + // MediaDevices is an EventTarget everywhere this ships, but a webview that + // exposes enumerateDevices without the event target shouldn't take the tab + // down with it — it just loses live refresh. + if (typeof navigator.mediaDevices?.addEventListener === "function") { + navigator.mediaDevices.addEventListener( + "devicechange", + () => { + void populateDevices(); + }, + { signal }, + ); + } inputSelect.addEventListener( "change", diff --git a/Client/tauri-client/src/components/settings/helpers.ts b/Client/tauri-client/src/components/settings/helpers.ts index f21384e6..9df8c4cd 100644 --- a/Client/tauri-client/src/components/settings/helpers.ts +++ b/Client/tauri-client/src/components/settings/helpers.ts @@ -5,12 +5,16 @@ import { createElement } from "@lib/dom"; import { applyThemeByName } from "@lib/themes"; +// Preference persistence lives in `@lib/preferences` so `lib/` modules can use +// it without importing from the component layer. Re-exported here so the +// settings tabs keep a single import site — and, critically, so both layers +// share one implementation (they used to be copy-pasted and had drifted). +export { STORAGE_PREFIX, loadPref, savePref, readMigratedStringPref } from "@lib/preferences"; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -export const STORAGE_PREFIX = "owncord:settings:"; - export const THEMES = { dark: { "--bg-primary": "#313338", @@ -40,31 +44,6 @@ export const THEMES = { export type ThemeName = keyof typeof THEMES; -// --------------------------------------------------------------------------- -// Preference helpers -// --------------------------------------------------------------------------- - -export function loadPref(key: string, fallback: T): T { - try { - const raw = localStorage.getItem(STORAGE_PREFIX + key); - if (raw === null) return fallback; - const parsed: unknown = JSON.parse(raw); - // Basic typeof guard against corrupted localStorage (covers boolean, - // number, string fallbacks used by current call sites). - if (typeof parsed !== typeof fallback) return fallback; - return parsed as T; - } catch { - return fallback; - } -} - -export function savePref(key: string, value: unknown): void { - localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); - // Dispatch a custom event so same-window listeners can invalidate caches. - // The native `storage` event only fires for cross-tab changes. - window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } })); -} - // --------------------------------------------------------------------------- // Accessible toggle creation // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/src/lib/appearance.ts b/Client/tauri-client/src/lib/appearance.ts new file mode 100644 index 00000000..b3e0fcd2 --- /dev/null +++ b/Client/tauri-client/src/lib/appearance.ts @@ -0,0 +1,56 @@ +/** + * Stored appearance preferences — applied at app startup. + * + * Extracted from SettingsOverlay so the startup path (main.ts, ConnectPage) + * can apply the stored theme/font/compact prefs without pulling in the full + * settings overlay (whose tabs statically import the LiveKit stack). + */ + +import { loadPref, applyTheme, THEMES } from "@components/settings/helpers"; +import type { ThemeName } from "@components/settings/helpers"; +import { getActiveThemeName, restoreTheme } from "@lib/themes"; +import { syncOsMotionListener } from "@lib/os-motion"; + +/** + * Apply stored appearance preferences (theme, font size, compact mode). + * Call at app startup so the UI doesn't flash default styles. + */ +export function applyStoredAppearance(): void { + const activeThemeName = getActiveThemeName(); + if (activeThemeName in THEMES) { + applyTheme(activeThemeName as ThemeName); + } else { + restoreTheme(); + } + try { + const rawAccent = localStorage.getItem("owncord:settings:accentColor"); + if (rawAccent !== null) { + const accent = JSON.parse(rawAccent); + if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) { + document.documentElement.style.setProperty("--accent", accent); + document.body.style.setProperty("--accent", accent); + } + } + } catch { + // Corrupted localStorage — keep the theme default accent. + } + document.documentElement.style.setProperty( + "--font-size", + `${loadPref("fontSize", 16)}px`, + ); + document.documentElement.classList.toggle( + "compact-mode", + loadPref("compactMode", false), + ); + document.documentElement.classList.toggle( + "reduced-motion", + loadPref("reducedMotion", false), + ); + document.documentElement.classList.toggle( + "high-contrast", + loadPref("highContrast", false), + ); + document.documentElement.classList.toggle("large-font", loadPref("largeFont", false)); + + syncOsMotionListener(loadPref("syncOsMotion", false)); +} diff --git a/Client/tauri-client/src/lib/deviceManager.ts b/Client/tauri-client/src/lib/deviceManager.ts index a07f2d87..1769fda9 100644 --- a/Client/tauri-client/src/lib/deviceManager.ts +++ b/Client/tauri-client/src/lib/deviceManager.ts @@ -146,7 +146,20 @@ export class DeviceManager { } async switchOutputDevice(deviceId: string): Promise { - if (this.room !== null) await this.room.switchActiveDevice("audiooutput", deviceId); - log.info("Switched output device", { deviceId }); + if (this.room === null) { + log.debug("Skipping output device switch — no active voice session"); + return; + } + // Mirrors switchInputDevice: switchActiveDevice rejects where setSinkId + // isn't available, and the settings tab fires this as a bare `void` call, + // so an unhandled rejection would leave the user staring at a selection + // that never took effect. + try { + await this.room.switchActiveDevice("audiooutput", deviceId); + log.info("Switched output device", { deviceId }); + } catch (err) { + 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 9754f446..16c11428 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -35,6 +35,7 @@ import { setTyping, } from "@stores/members.store"; import { + voiceStore, setVoiceStates, updateVoiceState, removeVoiceUser, @@ -55,13 +56,6 @@ import type { DmChannel } from "@stores/dm.store"; import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store"; import type { DmChannelPayload } from "./types"; import type { ApiClient } from "./api"; -import { - handleVoiceToken, - handleE2EEAnnounce, - handleE2EEOffer, - handleParticipantLeft, - isVoiceConnected, -} from "@lib/livekitSession"; import { notifyIncomingMessage } from "./notifications"; import { ensureIdentityKeyPublished } from "@lib/identity"; import { createLogger } from "./logger"; @@ -69,6 +63,13 @@ import { ServerMessageType as S } from "./protocolTypes"; const log = createLogger("dispatcher"); +/** Lazily import the LiveKit session module. livekit-client (~1.3 MB) is kept + * out of the entry chunk; voice handlers load it on first use. Once a voice + * flow has started the module is cached, so this resolves in a microtask. */ +function livekitSession(): Promise { + return import("@lib/livekitSession"); +} + /** Map a server DM channel payload to the client DmChannel type. */ function mapDmPayload(p: DmChannelPayload): DmChannel { return { @@ -138,13 +139,20 @@ export function wireDispatcher( setVoiceStates(payload.voice_states); // Defense-in-depth: if the ready payload shows us in a voice channel - // but we have no LiveKit room connection (e.g. after F5 reload), - // send voice_leave to clean up the stale state. The server should - // have already cleaned this up, but this handles edge cases. + // but we have no LiveKit session (e.g. after F5 reload), send + // voice_leave to clean up the stale state. The server should have + // already cleaned this up, but this handles edge cases. + // + // livekitSession is lazily imported, so instead of the synchronous + // isVoiceConnected() the check reads the voice store's lifecycle + // status: "idle" means no live or pending LiveKit session (a fresh + // reload always starts idle — exactly the stale case), while any other + // status means livekitSession is driving a session right now. const currentUserId = authStore.getState().user?.id ?? 0; const inVoicePerReady = currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId); - if (inVoicePerReady && !isVoiceConnected()) { + const voiceSessionActive = voiceStore.getState().voiceStatus !== "idle"; + if (inVoicePerReady && !voiceSessionActive) { log.warn("Stale voice state detected in ready payload — sending voice_leave"); ws.send({ type: "voice_leave", payload: {} }); leaveVoiceChannel(); @@ -413,7 +421,9 @@ export function wireDispatcher( ws.on(S.VOICE_LEAVE, (payload) => { removeVoiceUser(payload); // Notify E2EE state machine so key holder can rotate the room key. - void handleParticipantLeft(payload.user_id); + 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) { @@ -436,12 +446,14 @@ export function wireDispatcher( unsubs.push( ws.on(S.VOICE_TOKEN, (payload) => { - void handleVoiceToken( - payload.token, - payload.url, - payload.channel_id, - payload.direct_url, - payload.is_key_holder, + void livekitSession().then(({ handleVoiceToken }) => + handleVoiceToken( + payload.token, + payload.url, + payload.channel_id, + payload.direct_url, + payload.is_key_holder, + ), ); }), ); @@ -450,13 +462,17 @@ export function wireDispatcher( unsubs.push( ws.on(S.VOICE_E2EE_ANNOUNCE, (payload) => { - void handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature); + void livekitSession().then(({ handleE2EEAnnounce }) => + handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature), + ); }), ); unsubs.push( ws.on(S.VOICE_E2EE_OFFER, (payload) => { - void handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv); + void livekitSession().then(({ handleE2EEOffer }) => + handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv), + ); }), ); diff --git a/Client/tauri-client/src/lib/identity.ts b/Client/tauri-client/src/lib/identity.ts index f85c3663..41a842ef 100644 --- a/Client/tauri-client/src/lib/identity.ts +++ b/Client/tauri-client/src/lib/identity.ts @@ -177,10 +177,13 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise WsClient | null; + getServerHost: () => string | null; + getCurrentChannelId: () => number | null; +} + +// --- E2EEManager class --- + +export class E2EEManager { + /** E2EE key provider — shared across Room instances. The room key is generated + * and exchanged client-side via ECDH; the server never sees it. */ + readonly keyProvider = new ExternalE2EEKeyProvider(); + + // ── Client-side E2EE state (ECDH key exchange) ─────────────────────────── + /** Ephemeral ECDH P-256 keypair for the current voice session. */ + private _ecdhKeyPair: CryptoKeyPair | null = null; + /** The 256-bit symmetric room key (plaintext). Only held by the key holder + * initially; other participants receive it via ECDH-wrapped offers. */ + private _roomKey: Uint8Array | null = null; + /** Peer ECDH public keys indexed by userId. */ + private _peerPublicKeys: Map = new Map(); + /** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our + * ephemeral announces. Loaded lazily from the OS keyring, cached per session. */ + private _identityKeyPair: CryptoKeyPair | null = null; + /** True if this client is the key holder (longest-present participant). */ + private _isKeyHolder = false; + /** 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; + /** Guard: true while a key rotation is in progress (prevents concurrent rotations). */ + private _rotatingKey = false; + /** Set when a keyed-peer leave coincides with an in-flight rotation: the rekey + * is deferred (not dropped) and re-run when the current rotation finishes, so + * a member that left mid-rotation is excluded from the fresh room key. */ + private _rotationPending = false; + /** Monotonic counter incremented on every key rotation. handleOffer captures the + * epoch before async work and discards the result if epoch changed (stale offer). */ + private _e2eeEpoch = 0; + /** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */ + private _pendingAnnounces: Array<{ + userId: number; + publicKeyBase64: string; + signatureBase64?: string; + }> = []; + /** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */ + private _keyRotationTimer: ReturnType | null = null; + /** Interval between periodic key rotations (5 minutes). */ + private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000; + + constructor(private deps: E2EEDeps) {} + + // --- Internal state accessors (used by LiveKitSession's test-compat proxies) --- + + get peerPublicKeys(): Map { + return this._peerPublicKeys; + } + get epoch(): number { + return this._e2eeEpoch; + } + get rotatingKey(): boolean { + return this._rotatingKey; + } + set rotatingKey(value: boolean) { + this._rotatingKey = value; + } + get rotationPending(): boolean { + return this._rotationPending; + } + set rotationPending(value: boolean) { + this._rotationPending = value; + } + get pendingAnnounces(): Array<{ + userId: number; + publicKeyBase64: string; + signatureBase64?: string; + }> { + return this._pendingAnnounces; + } + + // ── Join-time key exchange ─────────────────────────────────────────────── + + /** + * Run the client-side E2EE key exchange for a join (called from + * connectAndSetup before room.connect). Generates a fresh ECDH keypair, + * drains queued announces, then either generates the room key (key holder) + * or announces and waits for the key holder's offer. + * + * Returns false when the key exchange timed out after retry — the caller + * surfaces the "e2ee_timeout" error and leaves voice. + */ + async setupKeyExchange(isKeyHolder: boolean, channelId: number): Promise { + // Generate a fresh ECDH keypair for this session. + this._ecdhKeyPair = await generateECDHKeyPair(); + this._peerPublicKeys.clear(); + clearPeerVerifications(); + const myPubKeyBase64 = await exportPublicKey(this._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); + + // Use server-authoritative is_key_holder from voice_token payload. + this._isKeyHolder = isKeyHolder; + + // 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 + // so a server-substituted peer key is caught here too. + const queued = this._pendingAnnounces.splice(0); + for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) { + // oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce + await this.handleAnnounce(qId, qKey, qSig); + log.info("E2EE: drained queued announce", { userId: qId }); + } + + 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 { + // Wait for the key holder to send us the room key via voice_e2ee_offer. + // This promise resolves when handleOffer() sets _roomKey. + log.info("E2EE: waiting for room key from key holder", { channelId }); + const roomKeyPromise = new Promise((resolve, reject) => { + this._roomKeyResolver = resolve; + this._roomKeyRejector = reject; + }); + // Announce BEFORE waiting (moved earlier per F3) so the key holder can + // offer immediately. The resolver is set above, so an immediate offer + // won't be missed. + this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload }); + // Wait up to 10s for the key holder to send an offer. If the first + // attempt times out, re-announce our public key (the offer may have been + // lost if the key holder disconnected mid-send) and wait 5s more. + let timeoutId: ReturnType | null = null; + const makeTimeout = (ms: number) => + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms); + }); + try { + await Promise.race([roomKeyPromise, makeTimeout(10_000)]); + } catch { + // First attempt timed out — re-announce and retry once. + if (timeoutId !== null) clearTimeout(timeoutId); + log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId }); + this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload }); + try { + await Promise.race([roomKeyPromise, makeTimeout(5_000)]); + } catch { + log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId }); + this._roomKeyResolver = null; + this._roomKeyRejector = null; + if (timeoutId !== null) clearTimeout(timeoutId); + return false; + } + } finally { + if (timeoutId !== null) clearTimeout(timeoutId); + } + this._roomKeyResolver = null; + this._roomKeyRejector = null; + } + return true; + } + + /** + * E2EE re-setup for auto-reconnect: regenerate the ECDH keypair for the new + * session (forward secrecy) and re-announce so other participants can re-wrap + * the room key for us. If we still have the room key from before disconnect, + * re-apply it now so audio works immediately; the key holder will send a + * fresh offer if the key was rotated during our absence. + */ + async reannounceForReconnect(): Promise { + this._ecdhKeyPair = await generateECDHKeyPair(); + this._peerPublicKeys.clear(); + clearPeerVerifications(); + if (this._roomKey) { + await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); + } + const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey); + const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey); + this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce }); + } + + // ── Identity signing (F3 TOFU) ────────────────────────────────────────── + + /** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad + * input (callers verifying a peer key already run inside try/catch). */ + private rawFromBase64(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + } + + /** Load (once per session) this client's long-term identity keypair from the + * OS keyring so we can sign ephemeral announces. Returns null when there is + * no server host (identity is host-scoped) — the announce then goes out + * unsigned and peers treat us as a legacy/unverified client. */ + private async ensureIdentityKeyPair(): Promise { + if (this._identityKeyPair) return this._identityKeyPair; + const host = this.deps.getServerHost(); + if (host === null) return null; + this._identityKeyPair = await getOrCreateIdentityKeyPair(host); + return this._identityKeyPair; + } + + /** Identity keys are host-scoped — the session drops the cached keypair when + * the host changes (and on cleanupAll) so we never sign an announce with + * another host's identity key. */ + clearIdentityKeyPair(): void { + this._identityKeyPair = null; + } + + /** Build the voice_e2ee_announce payload, signing the ephemeral public key + * with our identity key (F3). Signing failures degrade to an unsigned + * announce rather than blocking the join. */ + private async buildAnnouncePayload( + ephemeralPubBase64: string, + ): Promise<{ public_key: string; signature?: string }> { + try { + const idKeyPair = await this.ensureIdentityKeyPair(); + if (idKeyPair) { + const myUserId = authStore.getState().user?.id ?? 0; + const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64); + const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw); + return { public_key: ephemeralPubBase64, signature }; + } + } catch (err) { + log.error("E2EE: failed to sign announce — sending unsigned", err); + } + return { public_key: ephemeralPubBase64 }; + } + + /** + * F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce + * signature. Pins the identity key on first sight; on a later change it emits + * an identity-tofu "mismatch" (via the voice store) and blocks the peer until + * the user re-pins. Returns true when the announce may be accepted (verified, + * or a legacy peer with no identity key), false to reject/block. The store + * write is the surfaced verification state the voice panel reads. + * + * Compatibility posture (transition): + * - peer HAS a published identity key, signature missing/invalid → reject + * (fail closed); + * - peer has NO identity key (legacy client) → accept, mark unverified + * (pin-pending). + */ + private async verifyPeerAnnounce( + userId: number, + publicKeyBase64: string, + signatureBase64?: string, + ): Promise { + const publishedIdentity = + membersStore.getState().members.get(userId)?.identityPublicKey ?? null; + const host = this.deps.getServerHost(); + + // 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; + + // Pinned peer whose delivered key is absent or differs from the pin — + // possible server MITM. Block until the user re-pins. + if (pin !== null && publishedIdentity !== pin) { + setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { + userId, + }); + return false; + } + + // Genuine legacy peer: never pinned AND no published identity key — accept + // but mark unverified (pin-pending). This is the only case the compatibility + // posture keeps open. + if (!publishedIdentity) { + setPeerVerification({ userId, status: "unverified", safetyNumber: null }); + log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); + return true; + } + + // Verify the ephemeral-key signature against the trusted identity key + // (the pin when we have one, else the first-sight published key). + const anchorBase64 = pin ?? publishedIdentity; + const identityKey = await importIdentityPublicKey(anchorBase64); + const ephemeralRaw = this.rawFromBase64(publicKeyBase64); + const ok = signatureBase64 + ? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64) + : false; + if (!ok) { + // Fail closed: peer has an identity key but no valid signature (MITM). + setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); + return false; + } + + // First sight with a valid signature — pin the identity key now. + if (pin === null && host) { + await storeIdentityPin(host, String(userId), publishedIdentity); + log.info("E2EE: pinned peer identity key on first sight", { userId }); + } + const safetyNumber = await computeKeyFingerprint(identityKey); + setPeerVerification({ userId, status: "verified", safetyNumber }); + return true; + } + + /** + * F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key + * `verifiedKey` — the bytes whose fingerprint the caller displayed and the + * user confirmed out-of-band — overwriting the stored pin for {host,userId} + * and clearing the mismatch block (the identity-key analogue of accepting a + * changed TLS cert). A legitimate key rotation (reinstall / new device / + * wiped keyring) is thus recoverable instead of a permanent lockout; the next + * announce re-verifies against the new pin. + * + * The verified key MUST be passed in, never re-read from membersStore here: + * the store is server-writable (a `user_update` mutates it), so re-reading it + * would let a malicious server swap in an attacker key during the human + * out-of-band verification window and have us pin THAT — a TOCTOU that + * silently defeats the mismatch prompt. Returns false when there is no host + * or no key to pin. + */ + async rePinPeerIdentity(userId: number, verifiedKey: string): Promise { + const host = this.deps.getServerHost(); + if (!host || !verifiedKey) { + log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId }); + return false; + } + await storeIdentityPin(host, String(userId), verifiedKey); + clearPeerVerification(userId); + log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); + return true; + } + + // ── Client-side E2EE handlers (ECDH key exchange) ─────────────────────── + + /** + * Handle a voice_e2ee_announce from the server — another participant has + * announced their ECDH public key. 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( + userId: number, + publicKeyBase64: string, + signatureBase64?: string, + ): Promise { + // Queue if our keypair isn't ready yet (announce arrived during connectAndSetup). + if (!this._ecdhKeyPair) { + this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 }); + log.info("E2EE: queued announce (keypair not ready)", { userId }); + return; + } + try { + // ── F3 TOFU verification gate ────────────────────────────────────── + // Resolve the peer's identity key and verify the announce signature + // BEFORE storing the ECDH key or wrapping the room key. A malicious + // server that swaps user_id↔ephemeral-key or forges keys fails here. + if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) { + return; // rejected/blocked — do not store or wrap + } + + // Deduplicate: if the key is identical, skip the import but still + // re-send the room key offer (the peer may be re-requesting after a + // missed offer or reconnect). + const existingKey = this._peerPublicKeys.get(userId); + let peerKey: CryptoKey; + let isDuplicate = false; + if (existingKey) { + const existingB64 = await exportPublicKey(existingKey); + if (existingB64 === publicKeyBase64) { + peerKey = existingKey; + isDuplicate = true; + log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId }); + } else { + peerKey = await importPublicKey(publicKeyBase64); + log.warn("E2EE: peer public key changed (reconnect?)", { userId }); + } + } else { + peerKey = await importPublicKey(publicKeyBase64); + } + if (!isDuplicate) { + this._peerPublicKeys.set(userId, peerKey); + log.info("E2EE: received peer public key", { userId }); + } + + // If we're the key holder and have a room key, wrap it for the new peer. + // Capture keypair + roomKey before async work to avoid null dereference if + // clearState() runs concurrently. + const keypair = this._ecdhKeyPair; + const currentRoomKey = this._roomKey; + if (this._isKeyHolder && currentRoomKey && keypair) { + const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey); + this.deps.getWs()?.send({ + type: "voice_e2ee_offer", + payload: { target_user_id: userId, encrypted_key: encryptedKey, iv }, + }); + log.info("E2EE: sent room key offer to peer", { userId }); + } + } catch (err) { + log.error("E2EE: failed to handle announce", err); + } + } + + /** + * 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. + */ + async handleOffer( + fromUserId: number, + encryptedKeyBase64: string, + ivBase64: string, + ): Promise { + try { + const peerKey = this._peerPublicKeys.get(fromUserId); + if (!peerKey) { + log.warn("E2EE: received offer from unknown peer", { fromUserId }); + return; + } + const keypair = this._ecdhKeyPair; + if (!keypair) { + log.warn("E2EE: received offer but no ECDH keypair"); + return; + } + + // Capture epoch before async work — if a key rotation occurs during + // unwrap, the epoch will have advanced and we discard this stale result. + const epochBefore = this._e2eeEpoch; + + const unwrapped = await unwrapRoomKey( + keypair.privateKey, + peerKey, + encryptedKeyBase64, + ivBase64, + ); + + if (this._e2eeEpoch !== epochBefore) { + log.info("E2EE: discarding stale offer (epoch changed during unwrap)", { + fromUserId, + epochBefore, + epochNow: this._e2eeEpoch, + }); + return; + } + + this._roomKey = unwrapped; + await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); + log.info("E2EE: room key received and applied", { fromUserId }); + + // Resolve the pending connect promise if we were waiting for the key. + if (this._roomKeyResolver) { + this._roomKeyResolver(); + this._roomKeyResolver = null; + this._roomKeyRejector = null; + } + } catch (err) { + log.error("E2EE: failed to handle offer", err); + // Propagate decryption failure so the waiting setupKeyExchange unblocks. + if (this._roomKeyRejector) { + this._roomKeyRejector(err instanceof Error ? err : new Error(String(err))); + this._roomKeyResolver = null; + this._roomKeyRejector = null; + } + } + } + + /** + * 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 + * ALREADY the key holder and a peer that held the room key left, we also + * rotate — so the departed member's copy can no longer decrypt future audio + * against the untrusted SFU (membership forward secrecy). + * + * Key holder election: the participant with the lowest user ID among remaining + * participants is elected. This is deterministic and does not depend on Map + * insertion order (which is not guaranteed to match server join order). + */ + async handleParticipantLeft(userId: number): Promise { + const hadPeerKey = this._peerPublicKeys.has(userId); + this._peerPublicKeys.delete(userId); + clearPeerVerification(userId); + + const channelId = this.deps.getCurrentChannelId(); + if (!channelId) return; + + const state = voiceStore.getState(); + const channelUsers = state.voiceUsers.get(channelId); + if (!channelUsers || channelUsers.size === 0) return; + + // Elect key holder: lowest user_id among remaining participants. + let lowestUserId = Infinity; + for (const uid of channelUsers.keys()) { + if (uid < lowestUserId) lowestUserId = uid; + } + + const wasKeyHolder = this._isKeyHolder; + const myUserId = authStore.getState().user?.id ?? 0; + + if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) { + // Prevent concurrent rotations (e.g. two participants leave in rapid succession). + if (this._rotatingKey) { + log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId }); + return; + } + this._rotatingKey = true; + this._isKeyHolder = true; + log.info("E2EE: became key holder after participant left", { userId, channelId }); + + // Rotate the room key — generate a new one and distribute to all remaining peers. + try { + this._e2eeEpoch++; + this._roomKey = generateRoomKey(); + 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. + const keypair = this._ecdhKeyPair; + 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 }, + }); + } + 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 }); + } + } + } + } + } catch (err) { + log.error("E2EE: failed to rotate room key", err); + } finally { + this._rotatingKey = false; + } + // If a keyed peer left while this become-holder rotation was in flight, its + // rekey was deferred (not dropped) — run it now so the departed member is + // excluded from the fresh key; otherwise re-arm the periodic timer. + await this.drainPendingRotationOrArmTimer(); + } else if (wasKeyHolder && hadPeerKey) { + // Membership forward secrecy: I remain the key holder and a peer that held + // the room key left, so rotate + redistribute to the CURRENT peer set + // (which already excludes the leaver, deleted above) — otherwise the + // departed member keeps a valid room key against the untrusted SFU until + // the next periodic rotation. + if (this._rotatingKey) { + // A rotation is already in flight and may already have sent the current + // key to this leaver before they left. Don't DROP the rekey (that would + // leave the departed member holding a live key) — defer it so it re-runs + // when the in-flight rotation completes, excluding them. + this._rotationPending = true; + } else { + await this.rotateKeyPeriodically(); + } + } + } + + // ── Periodic key rotation ────────────────────────────────────────────────── + + /** Start the periodic key rotation timer (only meaningful for key holders). */ + private startKeyRotationTimer(): void { + this.clearKeyRotationTimer(); + if (!this._isKeyHolder) return; + this._keyRotationTimer = setTimeout(() => { + this._keyRotationTimer = null; + void this.rotateKeyPeriodically(); + }, E2EEManager.KEY_ROTATION_INTERVAL_MS); + log.debug("E2EE: key rotation timer started", { + intervalMs: E2EEManager.KEY_ROTATION_INTERVAL_MS, + }); + } + + private clearKeyRotationTimer(): void { + if (this._keyRotationTimer !== null) { + clearTimeout(this._keyRotationTimer); + this._keyRotationTimer = null; + } + } + + /** Rotate the room key on a timer tick (forward secrecy improvement). */ + async rotateKeyPeriodically(): Promise { + if (!this._isKeyHolder || this._rotatingKey) return; + const channelId = this.deps.getCurrentChannelId(); + if (!channelId) return; + + this._rotatingKey = true; + try { + this._e2eeEpoch++; + this._roomKey = generateRoomKey(); + await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); + 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, + }); + } + } catch (err) { + log.error("E2EE: periodic key rotation failed", err); + } finally { + this._rotatingKey = false; + } + + // Re-arm the periodic timer, or run a rotation deferred by a keyed-peer leave + // that coincided with this one. + await this.drainPendingRotationOrArmTimer(); + } + + /** After a rotation completes: if a keyed-peer leave coincided with it (its + * rekey was deferred, not dropped), run one more rotation to exclude the + * departed member; otherwise re-arm the periodic rotation timer. */ + private async drainPendingRotationOrArmTimer(): Promise { + if (this._rotationPending) { + this._rotationPending = false; + await this.rotateKeyPeriodically(); + return; + } + this.startKeyRotationTimer(); + } + + /** Clear all E2EE state (called on voice leave). The long-term identity + * keypair is intentionally NOT cleared here — it persists across calls to + * the same host (cleared only on host change / cleanupAll). */ + clearState(): void { + this._ecdhKeyPair = null; + this._roomKey = null; + this._peerPublicKeys.clear(); + clearPeerVerifications(); + this._isKeyHolder = false; + this._rotatingKey = false; + this._rotationPending = false; + this._e2eeEpoch = 0; + this._pendingAnnounces.length = 0; + this.clearKeyRotationTimer(); + // Reject (not resolve) so waiting setupKeyExchange sees a failure, not a + // silent success with no room key. + if (this._roomKeyRejector) { + this._roomKeyRejector(new Error("Voice session ended")); + } + this._roomKeyResolver = null; + this._roomKeyRejector = null; + } +} diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 6cc121c6..74594762 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -1,5 +1,5 @@ // LiveKit Session — lifecycle orchestrator for voice chat via LiveKit -import { Room, RoomEvent, ExternalE2EEKeyProvider } from "livekit-client"; +import { Room, RoomEvent } from "livekit-client"; import type { WsClient } from "@lib/ws"; import { voiceStore, @@ -11,32 +11,12 @@ import { setListenOnly, setVoiceStatus, } from "@stores/voice.store"; -import { authStore } from "@stores/auth.store"; import { loadPref } from "@components/settings/helpers"; import { createLogger } from "@lib/logger"; import { invoke } from "@tauri-apps/api/core"; import { AudioPipeline } from "@lib/audioPipeline"; import { AudioElements } from "@lib/audioElements"; -import { - generateECDHKeyPair, - exportPublicKey, - importPublicKey, - generateRoomKey, - roomKeyToBase64, - wrapRoomKey, - unwrapRoomKey, - signEphemeralKey, - verifyEphemeralKeySignature, - importIdentityPublicKey, - computeKeyFingerprint, -} from "@lib/e2eeCrypto"; -import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; -import { membersStore } from "@stores/members.store"; -import { - setPeerVerification, - clearPeerVerification, - clearPeerVerifications, -} from "@stores/voice.store"; +import { E2EEManager } from "@lib/livekitE2EE"; import { DeviceManager } from "@lib/deviceManager"; import { type VideoTrackDeps, @@ -147,45 +127,47 @@ export class LiveKitSession { /** Cached port for the local LiveKit TLS proxy (Rust-side, for self-signed cert support). */ private liveKitProxyPort: number | null = null; - /** E2EE key provider — shared across Room instances. The room key is generated - * and exchanged client-side via ECDH; the server never sees it. */ - private _e2eeKeyProvider = new ExternalE2EEKeyProvider(); + // ── Client-side E2EE (ECDH key exchange) — extracted to E2EEManager ────── + /** Owns all E2EE state and the key-exchange protocol: ECDH keypair, room-key + * generation/rotation, identity signing / TOFU verification (F3), and the + * announce/offer handlers. See livekitE2EE.ts. */ + private _e2ee = new E2EEManager({ + getWs: () => this.ws, + getServerHost: () => this.serverHost, + getCurrentChannelId: () => this._currentChannelId, + }); - // ── Client-side E2EE state (ECDH key exchange) ─────────────────────────── - /** Ephemeral ECDH P-256 keypair for the current voice session. */ - private _ecdhKeyPair: CryptoKeyPair | null = null; - /** The 256-bit symmetric room key (plaintext). Only held by the key holder - * initially; other participants receive it via ECDH-wrapped offers. */ - private _roomKey: Uint8Array | null = null; - /** Peer ECDH public keys indexed by userId. */ - private _peerPublicKeys: Map = new Map(); - /** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our - * ephemeral announces. Loaded lazily from the OS keyring, cached per session. */ - private _identityKeyPair: CryptoKeyPair | null = null; - /** True if this client is the key holder (longest-present participant). */ - private _isKeyHolder = false; - /** 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; - /** Guard: true while a key rotation is in progress (prevents concurrent rotations). */ - private _rotatingKey = false; - /** Set when a keyed-peer leave coincides with an in-flight rotation: the rekey - * is deferred (not dropped) and re-run when the current rotation finishes, so - * a member that left mid-rotation is excluded from the fresh room key. */ - private _rotationPending = false; - /** Monotonic counter incremented on every key rotation. handleE2EEOffer captures the - * epoch before async work and discards the result if epoch changed (stale offer). */ - private _e2eeEpoch = 0; - /** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */ - private _pendingAnnounces: Array<{ + // --- Test-visibility proxies (E2EE state lives in E2EEManager; unit tests + // reach these via `(session as any)` — keep the field names stable) --- + private get _peerPublicKeys(): Map { + return this._e2ee.peerPublicKeys; + } + private get _e2eeEpoch(): number { + return this._e2ee.epoch; + } + private get _rotatingKey(): boolean { + return this._e2ee.rotatingKey; + } + private set _rotatingKey(value: boolean) { + this._e2ee.rotatingKey = value; + } + private get _rotationPending(): boolean { + return this._e2ee.rotationPending; + } + private set _rotationPending(value: boolean) { + this._e2ee.rotationPending = value; + } + private get _pendingAnnounces(): Array<{ userId: number; publicKeyBase64: string; signatureBase64?: string; - }> = []; - /** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */ - private _keyRotationTimer: ReturnType | null = null; - /** Interval between periodic key rotations (5 minutes). */ - private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000; + }> { + return this._e2ee.pendingAnnounces; + } + /** Test-visibility delegate: periodic rotation lives on the E2EEManager. */ + private rotateKeyPeriodically(): Promise { + return this._e2ee.rotateKeyPeriodically(); + } // --- State transition (single writer) --- @@ -380,7 +362,7 @@ export class LiveKitSession { // End-to-end encryption: SFrame-based E2EE using a server-distributed // per-channel symmetric key. The SFU only sees encrypted frames. e2ee: { - keyProvider: this._e2eeKeyProvider, + keyProvider: this._e2ee.keyProvider, worker: new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)), }, }); @@ -478,18 +460,7 @@ export class LiveKitSession { // so audio works immediately; the key holder will send a fresh offer if // the key was rotated during our absence. // oxlint-disable-next-line no-await-in-loop -- must set up E2EE before connect - this._ecdhKeyPair = await generateECDHKeyPair(); - this._peerPublicKeys.clear(); - clearPeerVerifications(); - if (this._roomKey) { - // oxlint-disable-next-line no-await-in-loop -- must set key before connect - await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); - } - // oxlint-disable-next-line no-await-in-loop -- must export before connect - const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey); - // oxlint-disable-next-line no-await-in-loop -- must sign the announce before connect - const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey); - this.ws?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce }); + await this._e2ee.reannounceForReconnect(); // oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state await newRoom.connect(resolvedUrl, token); @@ -789,7 +760,7 @@ export class LiveKitSession { // Identity keys are host-scoped — drop the cached keypair when the host // changes so we never sign an announce with another host's identity key. if (host !== this.serverHost) { - this._identityKeyPair = null; + this._e2ee.clearIdentityKeyPair(); } this.serverHost = host; } @@ -868,82 +839,11 @@ export class LiveKitSession { // Non-key-holders block here waiting for the key holder's offer (up to // ~15s); key holders pass through near-instantly. setVoiceStatus("securing"); - // Generate a fresh ECDH keypair for this session. - this._ecdhKeyPair = await generateECDHKeyPair(); - this._peerPublicKeys.clear(); - clearPeerVerifications(); - const myPubKeyBase64 = await exportPublicKey(this._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); - - // Use server-authoritative is_key_holder from voice_token payload. - this._isKeyHolder = isKeyHolder ?? false; - - // 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 - // so a server-substituted peer key is caught here too. - const queued = this._pendingAnnounces.splice(0); - for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) { - // oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce - await this.handleE2EEAnnounce(qId, qKey, qSig); - log.info("E2EE: drained queued announce", { userId: qId }); - } - - if (this._isKeyHolder) { - // We're the first participant — generate the room key. - this._e2eeEpoch++; - this._roomKey = generateRoomKey(); - await this._e2eeKeyProvider.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.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); - } else { - // Wait for the key holder to send us the room key via voice_e2ee_offer. - // This promise resolves when handleE2EEOffer() sets _roomKey. - log.info("E2EE: waiting for room key from key holder", { channelId }); - const roomKeyPromise = new Promise((resolve, reject) => { - this._roomKeyResolver = resolve; - this._roomKeyRejector = reject; - }); - // Announce BEFORE waiting (moved earlier per F3) so the key holder can - // offer immediately. The resolver is set above, so an immediate offer - // won't be missed. - this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); - // Wait up to 10s for the key holder to send an offer. If the first - // attempt times out, re-announce our public key (the offer may have been - // lost if the key holder disconnected mid-send) and wait 5s more. - let timeoutId: ReturnType | null = null; - const makeTimeout = (ms: number) => - new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms); - }); - try { - await Promise.race([roomKeyPromise, makeTimeout(10_000)]); - } catch { - // First attempt timed out — re-announce and retry once. - if (timeoutId !== null) clearTimeout(timeoutId); - log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId }); - this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); - try { - await Promise.race([roomKeyPromise, makeTimeout(5_000)]); - } catch { - log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId }); - this._roomKeyResolver = null; - this._roomKeyRejector = null; - if (timeoutId !== null) clearTimeout(timeoutId); - this.onErrorCallback?.("e2ee_timeout"); - this.leaveVoice(false); - return false; - } - } finally { - if (timeoutId !== null) clearTimeout(timeoutId); - } - this._roomKeyResolver = null; - this._roomKeyRejector = null; + const keyExchangeOk = await this._e2ee.setupKeyExchange(isKeyHolder ?? false, channelId); + if (!keyExchangeOk) { + this.onErrorCallback?.("e2ee_timeout"); + this.leaveVoice(false); + return false; } for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { @@ -1188,491 +1088,46 @@ export class LiveKitSession { } } - // ── Identity signing (F3 TOFU) ────────────────────────────────────────── - - /** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad - * input (callers verifying a peer key already run inside try/catch). */ - private rawFromBase64(base64: string): Uint8Array { - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - } - - /** Load (once per session) this client's long-term identity keypair from the - * OS keyring so we can sign ephemeral announces. Returns null when there is - * no server host (identity is host-scoped) — the announce then goes out - * unsigned and peers treat us as a legacy/unverified client. */ - private async ensureIdentityKeyPair(): Promise { - if (this._identityKeyPair) return this._identityKeyPair; - if (this.serverHost === null) return null; - this._identityKeyPair = await getOrCreateIdentityKeyPair(this.serverHost); - return this._identityKeyPair; - } - - /** Build the voice_e2ee_announce payload, signing the ephemeral public key - * with our identity key (F3). Signing failures degrade to an unsigned - * announce rather than blocking the join. */ - private async buildAnnouncePayload( - ephemeralPubBase64: string, - ): Promise<{ public_key: string; signature?: string }> { - try { - const idKeyPair = await this.ensureIdentityKeyPair(); - if (idKeyPair) { - const myUserId = authStore.getState().user?.id ?? 0; - const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64); - const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw); - return { public_key: ephemeralPubBase64, signature }; - } - } catch (err) { - log.error("E2EE: failed to sign announce — sending unsigned", err); - } - return { public_key: ephemeralPubBase64 }; - } + // ── Client-side E2EE delegates (state + protocol live in E2EEManager) ─── /** - * F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce - * signature. Pins the identity key on first sight; on a later change it emits - * an identity-tofu "mismatch" (via the voice store) and blocks the peer until - * the user re-pins. Returns true when the announce may be accepted (verified, - * or a legacy peer with no identity key), false to reject/block. The store - * write is the surfaced verification state the voice panel reads. - * - * Compatibility posture (transition): - * - peer HAS a published identity key, signature missing/invalid → reject - * (fail closed); - * - peer has NO identity key (legacy client) → accept, mark unverified - * (pin-pending). - */ - private async verifyPeerAnnounce( - userId: number, - publicKeyBase64: string, - signatureBase64?: string, - ): Promise { - const publishedIdentity = - membersStore.getState().members.get(userId)?.identityPublicKey ?? null; - const host = this.serverHost; - - // 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; - - // Pinned peer whose delivered key is absent or differs from the pin — - // possible server MITM. Block until the user re-pins. - if (pin !== null && publishedIdentity !== pin) { - setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); - log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { - userId, - }); - return false; - } - - // Genuine legacy peer: never pinned AND no published identity key — accept - // but mark unverified (pin-pending). This is the only case the compatibility - // posture keeps open. - if (!publishedIdentity) { - setPeerVerification({ userId, status: "unverified", safetyNumber: null }); - log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); - return true; - } - - // Verify the ephemeral-key signature against the trusted identity key - // (the pin when we have one, else the first-sight published key). - const anchorBase64 = pin ?? publishedIdentity; - const identityKey = await importIdentityPublicKey(anchorBase64); - const ephemeralRaw = this.rawFromBase64(publicKeyBase64); - const ok = signatureBase64 - ? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64) - : false; - if (!ok) { - // Fail closed: peer has an identity key but no valid signature (MITM). - setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); - log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); - return false; - } - - // First sight with a valid signature — pin the identity key now. - if (pin === null && host) { - await storeIdentityPin(host, String(userId), publishedIdentity); - log.info("E2EE: pinned peer identity key on first sight", { userId }); - } - const safetyNumber = await computeKeyFingerprint(identityKey); - setPeerVerification({ userId, status: "verified", safetyNumber }); - return true; - } - - /** - * F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key - * `verifiedKey` — the bytes whose fingerprint the caller displayed and the - * user confirmed out-of-band — overwriting the stored pin for {host,userId} - * and clearing the mismatch block (the identity-key analogue of accepting a - * changed TLS cert). A legitimate key rotation (reinstall / new device / - * wiped keyring) is thus recoverable instead of a permanent lockout; the next - * announce re-verifies against the new pin. - * - * The verified key MUST be passed in, never re-read from membersStore here: - * the store is server-writable (a `user_update` mutates it), so re-reading it - * would let a malicious server swap in an attacker key during the human - * out-of-band verification window and have us pin THAT — a TOCTOU that - * silently defeats the mismatch prompt. Returns false when there is no host - * or no key to pin. + * F3 TOFU re-pin recovery: pin the exact identity key the user verified + * out-of-band, clearing a mismatch block. See E2EEManager.rePinPeerIdentity. */ async rePinPeerIdentity(userId: number, verifiedKey: string): Promise { - const host = this.serverHost; - if (!host || !verifiedKey) { - log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId }); - return false; - } - await storeIdentityPin(host, String(userId), verifiedKey); - clearPeerVerification(userId); - log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); - return true; + return this._e2ee.rePinPeerIdentity(userId, verifiedKey); } - // ── Client-side E2EE handlers (ECDH key exchange) ─────────────────────── - /** * Handle a voice_e2ee_announce from the server — another participant has - * announced their ECDH public key. 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. + * announced their ECDH public key. See E2EEManager.handleAnnounce. */ async handleE2EEAnnounce( userId: number, publicKeyBase64: string, signatureBase64?: string, ): Promise { - // Queue if our keypair isn't ready yet (announce arrived during connectAndSetup). - if (!this._ecdhKeyPair) { - this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 }); - log.info("E2EE: queued announce (keypair not ready)", { userId }); - return; - } - try { - // ── F3 TOFU verification gate ────────────────────────────────────── - // Resolve the peer's identity key and verify the announce signature - // BEFORE storing the ECDH key or wrapping the room key. A malicious - // server that swaps user_id↔ephemeral-key or forges keys fails here. - if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) { - return; // rejected/blocked — do not store or wrap - } - - // Deduplicate: if the key is identical, skip the import but still - // re-send the room key offer (the peer may be re-requesting after a - // missed offer or reconnect). - const existingKey = this._peerPublicKeys.get(userId); - let peerKey: CryptoKey; - let isDuplicate = false; - if (existingKey) { - const existingB64 = await exportPublicKey(existingKey); - if (existingB64 === publicKeyBase64) { - peerKey = existingKey; - isDuplicate = true; - log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId }); - } else { - peerKey = await importPublicKey(publicKeyBase64); - log.warn("E2EE: peer public key changed (reconnect?)", { userId }); - } - } else { - peerKey = await importPublicKey(publicKeyBase64); - } - if (!isDuplicate) { - this._peerPublicKeys.set(userId, peerKey); - log.info("E2EE: received peer public key", { userId }); - } - - // If we're the key holder and have a room key, wrap it for the new peer. - // Capture keypair + roomKey before async work to avoid null dereference if - // clearE2EEState() runs concurrently. - const keypair = this._ecdhKeyPair; - const currentRoomKey = this._roomKey; - if (this._isKeyHolder && currentRoomKey && keypair) { - const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey); - this.ws?.send({ - type: "voice_e2ee_offer", - payload: { target_user_id: userId, encrypted_key: encryptedKey, iv }, - }); - log.info("E2EE: sent room key offer to peer", { userId }); - } - } catch (err) { - log.error("E2EE: failed to handle announce", err); - } + return this._e2ee.handleAnnounce(userId, publicKeyBase64, signatureBase64); } /** * 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. + * the encrypted room key. See E2EEManager.handleOffer. */ async handleE2EEOffer( fromUserId: number, encryptedKeyBase64: string, ivBase64: string, ): Promise { - try { - const peerKey = this._peerPublicKeys.get(fromUserId); - if (!peerKey) { - log.warn("E2EE: received offer from unknown peer", { fromUserId }); - return; - } - const keypair = this._ecdhKeyPair; - if (!keypair) { - log.warn("E2EE: received offer but no ECDH keypair"); - return; - } - - // Capture epoch before async work — if a key rotation occurs during - // unwrap, the epoch will have advanced and we discard this stale result. - const epochBefore = this._e2eeEpoch; - - const unwrapped = await unwrapRoomKey( - keypair.privateKey, - peerKey, - encryptedKeyBase64, - ivBase64, - ); - - if (this._e2eeEpoch !== epochBefore) { - log.info("E2EE: discarding stale offer (epoch changed during unwrap)", { - fromUserId, - epochBefore, - epochNow: this._e2eeEpoch, - }); - return; - } - - this._roomKey = unwrapped; - await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); - log.info("E2EE: room key received and applied", { fromUserId }); - - // Resolve the pending connect promise if we were waiting for the key. - if (this._roomKeyResolver) { - this._roomKeyResolver(); - this._roomKeyResolver = null; - this._roomKeyRejector = null; - } - } catch (err) { - log.error("E2EE: failed to handle offer", err); - // Propagate decryption failure so the waiting connectAndSetup unblocks. - if (this._roomKeyRejector) { - this._roomKeyRejector(err instanceof Error ? err : new Error(String(err))); - this._roomKeyResolver = null; - this._roomKeyRejector = null; - } - } + return this._e2ee.handleOffer(fromUserId, encryptedKeyBase64, ivBase64); } /** - * 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 - * ALREADY the key holder and a peer that held the room key left, we also - * rotate — so the departed member's copy can no longer decrypt future audio - * against the untrusted SFU (membership forward secrecy). - * - * Key holder election: the participant with the lowest user ID among remaining - * participants is elected. This is deterministic and does not depend on Map - * insertion order (which is not guaranteed to match server join order). + * Handle a participant leaving the voice channel (key-holder election and + * membership-forward-secrecy rekey). See E2EEManager.handleParticipantLeft. */ async handleParticipantLeft(userId: number): Promise { - const hadPeerKey = this._peerPublicKeys.has(userId); - this._peerPublicKeys.delete(userId); - clearPeerVerification(userId); - - const channelId = this._currentChannelId; - if (!channelId) return; - - const state = voiceStore.getState(); - const channelUsers = state.voiceUsers.get(channelId); - if (!channelUsers || channelUsers.size === 0) return; - - // Elect key holder: lowest user_id among remaining participants. - let lowestUserId = Infinity; - for (const uid of channelUsers.keys()) { - if (uid < lowestUserId) lowestUserId = uid; - } - - const wasKeyHolder = this._isKeyHolder; - const myUserId = authStore.getState().user?.id ?? 0; - - if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) { - // Prevent concurrent rotations (e.g. two participants leave in rapid succession). - if (this._rotatingKey) { - log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId }); - return; - } - this._rotatingKey = true; - this._isKeyHolder = true; - log.info("E2EE: became key holder after participant left", { userId, channelId }); - - // Rotate the room key — generate a new one and distribute to all remaining peers. - try { - this._e2eeEpoch++; - this._roomKey = generateRoomKey(); - await this._e2eeKeyProvider.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. - const keypair = this._ecdhKeyPair; - 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.ws?.send({ - type: "voice_e2ee_offer", - payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, - }); - } - 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.ws?.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 }); - } - } - } - } - } catch (err) { - log.error("E2EE: failed to rotate room key", err); - } finally { - this._rotatingKey = false; - } - // If a keyed peer left while this become-holder rotation was in flight, its - // rekey was deferred (not dropped) — run it now so the departed member is - // excluded from the fresh key; otherwise re-arm the periodic timer. - await this.drainPendingRotationOrArmTimer(); - } else if (wasKeyHolder && hadPeerKey) { - // Membership forward secrecy: I remain the key holder and a peer that held - // the room key left, so rotate + redistribute to the CURRENT peer set - // (which already excludes the leaver, deleted above) — otherwise the - // departed member keeps a valid room key against the untrusted SFU until - // the next periodic rotation. - if (this._rotatingKey) { - // A rotation is already in flight and may already have sent the current - // key to this leaver before they left. Don't DROP the rekey (that would - // leave the departed member holding a live key) — defer it so it re-runs - // when the in-flight rotation completes, excluding them. - this._rotationPending = true; - } else { - await this.rotateKeyPeriodically(); - } - } - } - - // ── Periodic key rotation ────────────────────────────────────────────────── - - /** Start the periodic key rotation timer (only meaningful for key holders). */ - private startKeyRotationTimer(): void { - this.clearKeyRotationTimer(); - if (!this._isKeyHolder) return; - this._keyRotationTimer = setTimeout(() => { - this._keyRotationTimer = null; - void this.rotateKeyPeriodically(); - }, LiveKitSession.KEY_ROTATION_INTERVAL_MS); - log.debug("E2EE: key rotation timer started", { - intervalMs: LiveKitSession.KEY_ROTATION_INTERVAL_MS, - }); - } - - private clearKeyRotationTimer(): void { - if (this._keyRotationTimer !== null) { - clearTimeout(this._keyRotationTimer); - this._keyRotationTimer = null; - } - } - - /** Rotate the room key on a timer tick (forward secrecy improvement). */ - private async rotateKeyPeriodically(): Promise { - if (!this._isKeyHolder || this._rotatingKey) return; - const channelId = this._currentChannelId; - if (!channelId) return; - - this._rotatingKey = true; - try { - this._e2eeEpoch++; - this._roomKey = generateRoomKey(); - await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); - 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.ws?.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, - }); - } - } catch (err) { - log.error("E2EE: periodic key rotation failed", err); - } finally { - this._rotatingKey = false; - } - - // Re-arm the periodic timer, or run a rotation deferred by a keyed-peer leave - // that coincided with this one. - await this.drainPendingRotationOrArmTimer(); - } - - /** After a rotation completes: if a keyed-peer leave coincided with it (its - * rekey was deferred, not dropped), run one more rotation to exclude the - * departed member; otherwise re-arm the periodic rotation timer. */ - private async drainPendingRotationOrArmTimer(): Promise { - if (this._rotationPending) { - this._rotationPending = false; - await this.rotateKeyPeriodically(); - return; - } - this.startKeyRotationTimer(); - } - - /** Clear all E2EE state (called on voice leave). The long-term identity - * keypair is intentionally NOT cleared here — it persists across calls to - * the same host (cleared only on host change / cleanupAll). */ - private clearE2EEState(): void { - this._ecdhKeyPair = null; - this._roomKey = null; - this._peerPublicKeys.clear(); - clearPeerVerifications(); - this._isKeyHolder = false; - this._rotatingKey = false; - this._rotationPending = false; - this._e2eeEpoch = 0; - this._pendingAnnounces.length = 0; - this.clearKeyRotationTimer(); - // Reject (not resolve) so waiting connectAndSetup sees a failure, not a - // silent success with no room key. - if (this._roomKeyRejector) { - this._roomKeyRejector(new Error("Voice session ended")); - } - this._roomKeyResolver = null; - this._roomKeyRejector = null; + return this._e2ee.handleParticipantLeft(userId); } /** Retry microphone permission after being in listen-only mode. */ @@ -1728,7 +1183,7 @@ export class LiveKitSession { room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err)); } // Clear client-side E2EE state (ECDH keypair, room key, peer keys). - this.clearE2EEState(); + this._e2ee.clearState(); // 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. @@ -1750,7 +1205,7 @@ export class LiveKitSession { this.ws = null; this.serverHost = null; this.liveKitProxyPort = null; - this._identityKeyPair = null; + this._e2ee.clearIdentityKeyPair(); // Stop the Rust-side TLS proxy (fire-and-forget). invoke("stop_livekit_proxy").catch((err) => log.warn("Failed to stop LiveKit proxy", err)); } diff --git a/Client/tauri-client/src/lib/logger.ts b/Client/tauri-client/src/lib/logger.ts index eed895b2..4fe2c89a 100644 --- a/Client/tauri-client/src/lib/logger.ts +++ b/Client/tauri-client/src/lib/logger.ts @@ -1,5 +1,7 @@ // Step 1.12 — Structured client-side logger +import { readMigratedStringPref } from "./preferences"; + export type LogLevel = "debug" | "info" | "warn" | "error"; export interface LogEntry { @@ -115,6 +117,45 @@ export function setLogLevel(level: LogLevel): void { currentLevel = level; } +/** + * Get the current effective minimum log level (as applied by applyStoredLogLevel + * / setLogLevel). Reflects the real runtime level, which may differ from any + * saved "logs_min_level" pref when none is stored (dev defaults to debug, + * production to info). + */ +export function getLogLevel(): LogLevel { + return currentLevel; +} + +const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"]; + +/** Pref key for the minimum level persisted by the Logs settings tab. */ +const MIN_LEVEL_PREF_KEY = "logs_min_level"; + +function readStoredLogLevel(): LogLevel | "" { + return readMigratedStringPref(MIN_LEVEL_PREF_KEY, "", ["", ...LOG_LEVELS]); +} + +/** + * Apply the minimum level persisted by the Logs settings tab ("logs_min_level" + * pref, including legacy-key migration), falling back to `fallback` when no + * level is stored. Call once at startup before anything logs. + */ +export function applyStoredLogLevel(fallback: LogLevel): void { + const stored = readStoredLogLevel(); + currentLevel = stored === "" ? fallback : stored; +} + +// Live updates: the Logs settings tab persists level changes via savePref, +// which dispatches "owncord:pref-change" for same-window listeners. +if (typeof window !== "undefined") { + window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => { + if (e.detail?.key !== MIN_LEVEL_PREF_KEY) return; + const stored = readStoredLogLevel(); + if (stored !== "") currentLevel = stored; + }) as EventListener); +} + /** * Add a listener for log entries (e.g., to write to file via Tauri). */ diff --git a/Client/tauri-client/src/lib/navigation-guard.ts b/Client/tauri-client/src/lib/navigation-guard.ts new file mode 100644 index 00000000..18713082 --- /dev/null +++ b/Client/tauri-client/src/lib/navigation-guard.ts @@ -0,0 +1,23 @@ +/** + * Navigation generation guard — protects async page mounts against the + * destroy-before-mount race. A render that awaits a dynamic import captures + * a generation via begin(); when the import resolves it asks the returned + * predicate whether it is still the latest navigation, and discards the + * mount if a newer navigation superseded it. + */ + +export interface NavigationGuard { + /** Start a new navigation. Returns a predicate that reports whether this + * navigation is still the latest one. */ + begin(): () => boolean; +} + +export function createNavigationGuard(): NavigationGuard { + let generation = 0; + return { + begin(): () => boolean { + const started = ++generation; + return () => started === generation; + }, + }; +} diff --git a/Client/tauri-client/src/lib/notifications.ts b/Client/tauri-client/src/lib/notifications.ts index a059cf8b..9c93446b 100644 --- a/Client/tauri-client/src/lib/notifications.ts +++ b/Client/tauri-client/src/lib/notifications.ts @@ -3,7 +3,8 @@ * and plays sounds for incoming messages based on user preferences. */ -import { loadPref } from "@components/settings/helpers"; +import { loadPref } from "./preferences"; +import { loadUserStatus } from "./userStatus"; import { authStore } from "@stores/auth.store"; import { channelsStore } from "@stores/channels.store"; import type { ChatMessagePayload } from "./types"; @@ -51,6 +52,11 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { return; } + // Do Not Disturb — the settings panel promises "You will not receive desktop + // notifications", so honour it for the popup and the chime. The taskbar + // flash stays: it's a passive hint, not a notification. + const dnd = loadUserStatus() === "dnd"; + const channelName = getChannelName(payload.channel_id); // oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability @@ -64,7 +70,7 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { const body = sanitizeNotif(payload.content, 100); // Desktop notification - if (loadPref("desktopNotifications", true)) { + if (!dnd && loadPref("desktopNotifications", true)) { fireDesktopNotification(title, body); } @@ -74,7 +80,7 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { } // Notification sound - if (loadPref("notificationSounds", true)) { + if (!dnd && loadPref("notificationSounds", true)) { playNotificationSound(); } } diff --git a/Client/tauri-client/src/lib/permissions.ts b/Client/tauri-client/src/lib/permissions.ts index 506ee7fd..c82a6cc2 100644 --- a/Client/tauri-client/src/lib/permissions.ts +++ b/Client/tauri-client/src/lib/permissions.ts @@ -1,4 +1,6 @@ import { Permission } from "./types"; +import { authStore } from "@stores/auth.store"; +import { channelsStore } from "@stores/channels.store"; /** Bitmask with every permission bit set. */ const ALL_PERMISSIONS = 0x7fffffff; @@ -55,3 +57,30 @@ export function computeEffective(basePerms: number, allow: number, deny: number) export function isAdministrator(userPerms: number): boolean { return (userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR; } + +/** + * Effective permission bits for the signed-in user, from the role list the + * server sends in `ready`. Returns 0 when the role is unknown (pre-`ready`, + * or a role the server didn't send) — deny by default. + */ +export function currentUserPermissions(): number { + const roleName = authStore.getState().user?.role; + if (roleName === undefined || roleName === null) return 0; + const role = channelsStore + .getState() + .roles.find((r) => r.name.toLowerCase() === roleName.toLowerCase()); + return role?.permissions ?? 0; +} + +/** + * Whether the signed-in user holds `perm`. Drives affordances only — the + * server is still the authority on every action. + */ +export function currentUserHasPermission(perm: Permission): boolean { + return hasPermission(currentUserPermissions(), perm); +} + +/** Shorthand for the MANAGE_MESSAGES bit (delete others' messages, bypass slow mode). */ +export function canManageMessages(): boolean { + return currentUserHasPermission(Permission.MANAGE_MESSAGES); +} diff --git a/Client/tauri-client/src/lib/preferences.ts b/Client/tauri-client/src/lib/preferences.ts index 7415166a..85256ff2 100644 --- a/Client/tauri-client/src/lib/preferences.ts +++ b/Client/tauri-client/src/lib/preferences.ts @@ -39,3 +39,43 @@ export function savePref(key: string, value: unknown): void { // localStorage may throw on quota exceeded or when storage is disabled. } } + +/** + * Read a string-valued pref restricted to `allowedValues`, migrating a legacy + * unprefixed localStorage entry forward when the prefixed key is unset. + * Legacy values were stored either raw or JSON-encoded; a migrated value is + * re-saved under the prefixed key via savePref. + */ +export function readMigratedStringPref( + key: string, + fallback: T, + allowedValues: readonly T[], +): T { + const currentRaw = localStorage.getItem(STORAGE_PREFIX + key); + if (currentRaw !== null) { + try { + const currentValue: unknown = JSON.parse(currentRaw); + if (typeof currentValue === "string" && allowedValues.includes(currentValue as T)) { + return currentValue as T; + } + } catch { + // Ignore corrupted current storage and fall back below. + } + } + + const legacyRaw = localStorage.getItem(key); + if (legacyRaw !== null) { + let legacyValue: unknown = legacyRaw; + try { + legacyValue = JSON.parse(legacyRaw); + } catch { + // Legacy values were previously stored as raw strings. + } + if (typeof legacyValue === "string" && allowedValues.includes(legacyValue as T)) { + savePref(key, legacyValue); + return legacyValue as T; + } + } + + return fallback; +} diff --git a/Client/tauri-client/src/lib/ptt.ts b/Client/tauri-client/src/lib/ptt.ts index 15ea5f61..bd946840 100644 --- a/Client/tauri-client/src/lib/ptt.ts +++ b/Client/tauri-client/src/lib/ptt.ts @@ -6,7 +6,6 @@ import { loadPref, savePref } from "@components/settings/helpers"; import { voiceStore } from "@stores/voice.store"; -import { setMuted } from "./livekitSession"; import { createLogger } from "./logger"; const log = createLogger("ptt"); @@ -100,8 +99,16 @@ export async function initPtt(): Promise { const channelId = voiceStore.getState().currentChannelId; if (channelId === null) return; - setMuted(!event.payload); - log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted"); + // 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"); + }) + .catch((e) => log.warn("Failed to apply PTT mute", e)); }); pttUnsubscribe = unsub; diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index f57729d8..19f93bbb 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -21,16 +21,32 @@ export type VoiceQuality = "low" | "medium" | "high"; export type ReactionAction = "add" | "remove"; /** WebSocket error codes returned by the server. */ +/** + * Error codes the server can send over the socket. Mirrors + * `Server/ws/errors.go` — the union was missing more than half of them + * (SLOW_MODE, CONFLICT, BAD_REQUEST…), so code that switched on it could not + * name the cases the server actually emits. + */ export type WsErrorCode = - | "BANNED" - | "FORBIDDEN" + | "BAD_REQUEST" + | "INTERNAL" | "NOT_FOUND" + | "FORBIDDEN" | "RATE_LIMITED" - | "INVALID_INPUT" - | "SERVER_ERROR" + | "ALREADY_JOINED" | "CHANNEL_FULL" | "VOICE_ERROR" - | "VIDEO_LIMIT"; + | "VIDEO_LIMIT" + | "BANNED" + | "INVALID_JSON" + | "UNKNOWN_TYPE" + | "SLOW_MODE" + | "CONFLICT" + | "BAD_PAYLOAD" + | "NOT_KEY_HOLDER" + // Kept for older servers / existing call sites. + | "INVALID_INPUT" + | "SERVER_ERROR"; /** REST API error codes. */ export type ApiErrorCode = @@ -104,6 +120,11 @@ export interface ReadyChannel { * server still enforces. Absent from older servers. */ readonly can_send?: boolean; + /** + * Per-channel cooldown in seconds (0 = off). Drives the composer's + * slow-mode countdown; the server still enforces. Absent from older servers. + */ + readonly slow_mode?: number; } /** Member object in the ready payload. */ @@ -243,12 +264,14 @@ export interface ChannelCreatePayload { readonly type: ChannelType; readonly category: string | null; readonly position: number; + readonly slow_mode?: number; } export interface ChannelUpdatePayload { readonly id: number; readonly name?: string; readonly position?: number; + readonly slow_mode?: number; } export interface ChannelDeletePayload { diff --git a/Client/tauri-client/src/lib/userStatus.ts b/Client/tauri-client/src/lib/userStatus.ts new file mode 100644 index 00000000..8e5b17ce --- /dev/null +++ b/Client/tauri-client/src/lib/userStatus.ts @@ -0,0 +1,49 @@ +/** + * Selected presence status — the single client-side source of truth. + * + * Both status surfaces (the settings Account tab and the UserBar picker) read + * and write through here, so they can't drift apart, and consumers such as the + * notification service can ask "is the user in Do Not Disturb?" without + * reaching into a store that only tracks *other* members' presence. + */ + +import type { UserStatus } from "./types"; +import { loadPref, savePref } from "./preferences"; + +export const USER_STATUS_PREF_KEY = "userStatus"; + +const VALID_STATUSES: readonly UserStatus[] = ["online", "idle", "dnd", "offline"]; + +function isUserStatus(value: string): value is UserStatus { + return (VALID_STATUSES as readonly string[]).includes(value); +} + +/** The status the user last selected, defaulting to "online". */ +export function loadUserStatus(): UserStatus { + const raw = loadPref(USER_STATUS_PREF_KEY, "online"); + return isUserStatus(raw) ? raw : "online"; +} + +/** Persist the selected status and notify same-window listeners. */ +export function saveUserStatus(status: UserStatus): void { + savePref(USER_STATUS_PREF_KEY, status); +} + +/** + * Run `onChange` whenever the selected status changes anywhere in this window. + * Returns an unsubscribe function. + */ +export function onUserStatusChange( + onChange: (status: UserStatus) => void, + options?: { signal?: AbortSignal }, +): () => void { + const handler = (e: Event): void => { + const detail = (e as CustomEvent<{ key?: string }>).detail; + if (detail?.key !== USER_STATUS_PREF_KEY) return; + onChange(loadUserStatus()); + }; + window.addEventListener("owncord:pref-change", handler, { signal: options?.signal }); + return () => { + window.removeEventListener("owncord:pref-change", handler); + }; +} diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index a14d8550..64ec7b89 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -14,15 +14,14 @@ import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher"; import { authStore, clearAuth } from "@stores/auth.store"; import { setTransientError } from "@stores/ui.store"; import { voiceStore, leaveVoiceChannel } from "@stores/voice.store"; -import { leaveVoice as voiceSessionLeave } from "@lib/livekitSession"; import { createConnectPage } from "@pages/ConnectPage"; -import { createMainPage } from "@pages/MainPage"; -import { applyStoredAppearance } from "@components/SettingsOverlay"; +import { applyStoredAppearance } from "@lib/appearance"; import { restoreTheme } from "@lib/themes"; import { initPtt } from "@lib/ptt"; +import { createNavigationGuard } from "@lib/navigation-guard"; import { createConnectedOverlay } from "@components/ConnectedOverlay"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; -import { createLogger } from "@lib/logger"; +import { createLogger, applyStoredLogLevel } from "@lib/logger"; import { initLogPersistence, flushLogs } from "@lib/logPersistence"; import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials"; import { initWindowState } from "@lib/window-state"; @@ -33,8 +32,24 @@ import type { CertTofuEvent } from "@lib/ws"; import { openUrl } from "@tauri-apps/plugin-opener"; +// Gate the log level before anything logs: debug entries are serialized and +// persisted to disk, so in production the level must filter real work, not +// just console noise. Honors the level saved on the Logs settings tab; when +// unset, dev builds keep full debug output and production defaults to info. +applyStoredLogLevel(import.meta.env.DEV ? "debug" : "info"); + const log = createLogger("main"); +// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded +// lazily so it stays out of the startup path. When a voice session exists the +// module is necessarily already loaded, so this import resolves from the +// module cache in a microtask. +function voiceSessionLeave(sendWsLeave: boolean): void { + void import("@lib/livekitSession") + .then(({ leaveVoice }) => leaveVoice(sendWsLeave)) + .catch((e) => log.warn("Failed to leave voice session", e)); +} + // Disable the default browser context menu globally. document.addEventListener("contextmenu", (e) => { e.preventDefault(); @@ -103,6 +118,11 @@ const ws = createWsClient(); wireConnectionStatus(ws); const profileManager = createProfileManager(createTauriBackend()); let dispatcherCleanup: (() => void) | null = null; +// Tears down the session-scoped WS listeners registered in wirePostAuth +// (user_update, onStateChange, ready). dispatcherCleanup only clears +// dispatcher-registered handlers, so these need their own teardown to avoid +// accumulating across login/logout/retry cycles. +let sessionCleanup: (() => void) | null = null; let connectedOverlay: ConnectedOverlayControl | null = null; let lastConnectHost = ""; let lastConnectToken = ""; @@ -241,8 +261,13 @@ function runHealthChecks( } } +// Guards the async MainPage mount below against the destroy-before-mount race: +// a stale mount is discarded when a newer navigation supersedes it. +const navGuard = createNavigationGuard(); + // Render the appropriate page based on router state -function renderPage(pageId: "connect" | "main"): void { +async function renderPage(pageId: "connect" | "main"): Promise { + const isCurrentNavigation = navGuard.begin(); log.info("Navigating to page", { pageId }); // Destroy previous page currentPage?.destroy?.(); @@ -261,6 +286,15 @@ function renderPage(pageId: "connect" | "main"): void { rememberPassword = true, ): void { log.info("Post-auth wiring", { host, username }); + // Tear down any prior session wiring so listeners and the connected + // overlay never stack across a retry (a second wirePostAuth without an + // intervening logout). + sessionCleanup?.(); + sessionCleanup = null; + dispatcherCleanup?.(); + dispatcherCleanup = null; + connectedOverlay?.destroy(); + connectedOverlay = null; api.setConfig({ token }); // Store token in authStore so the dispatcher's auth_ok handler has it authStore.setState((prev) => ({ ...prev, token })); @@ -270,6 +304,10 @@ function renderPage(pageId: "connect" | "main"): void { dispatcherCleanup = wireDispatcher(ws, api); log.info("Dispatcher wired, connecting WS"); + // Session-scoped WS listeners — collected so they're all removed together + // on logout/disconnect (or the next wirePostAuth). + const sessionUnsubs: Array<() => void> = []; + // BUG-135: Only persist credentials when the user opted in. if (rememberPassword) { saveCredential(host, username, token, password) @@ -285,21 +323,31 @@ function renderPage(pageId: "connect" | "main"): void { } // Update saved credentials when the current user changes their username. - 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); + 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); + } } - } - }); + }), + ); const unsubState = ws.onStateChange((wsState) => { log.debug("WS state change", { state: wsState }); if (wsState === "connected") { + // Stop listening once connected so a later transition can't fire this + // handler again (which would append a second overlay). unsubState(); + // Pre-warm the lazily-loaded MainPage chunk (and the LiveKit stack + // behind it) so navigating past the connected overlay doesn't wait + // on a dynamic import. + void import("@pages/MainPage"); const auth = authStore.getState(); + // Ensure exactly one overlay exists at a time. + connectedOverlay?.destroy(); connectedOverlay = createConnectedOverlay({ serverName: auth.serverName ?? host, username: auth.user?.username ?? username, @@ -317,8 +365,20 @@ function renderPage(pageId: "connect" | "main"): void { unsubReady(); connectedOverlay?.markReady(); }); + sessionUnsubs.push(unsubReady); + } else if (wsState === "disconnected") { + // Terminal non-connected transition (auth_error, cert-mismatch reject, + // or intentional disconnect before ever connecting): drop the handler + // so it doesn't linger and fire on a later connect. + unsubState(); } }); + sessionUnsubs.push(unsubState); + + sessionCleanup = () => { + for (const unsub of sessionUnsubs) unsub(); + sessionUnsubs.length = 0; + }; } // Track partial auth state for TOTP flow @@ -523,6 +583,14 @@ function renderPage(pageId: "connect" | "main"): void { } })(); } else { + // MainPage (and the LiveKit voice stack it statically imports) loads + // lazily so it stays out of the startup path. The chunk is pre-warmed as + // soon as the WS connect succeeds, so this normally resolves from the + // module cache. + const { createMainPage } = await import("@pages/MainPage"); + // A newer navigation may have superseded this one while the chunk loaded; + // mounting now would fight the page that navigation rendered. + if (!isCurrentNavigation()) return; const mainPage = createMainPage({ ws, api }); safeMount(mainPage, appEl!); currentPage = mainPage; @@ -530,7 +598,9 @@ function renderPage(pageId: "connect" | "main"): void { } // Listen for navigation changes -router.onNavigate(renderPage); +router.onNavigate((pageId) => { + void renderPage(pageId); +}); // Handle logout / disconnect authStore.subscribeSelector( @@ -546,6 +616,8 @@ authStore.subscribeSelector( } dispatcherCleanup?.(); dispatcherCleanup = null; + sessionCleanup?.(); + sessionCleanup = null; ws.disconnect(); lastConnectToken = ""; lastConnectHost = ""; @@ -570,8 +642,9 @@ window.addEventListener("beforeunload", () => { void flushLogs(); }); -// Initial render -renderPage(router.getCurrentPage()); +// Initial render (fire-and-forget — the initial page is "connect", whose +// render branch is synchronous) +void renderPage(router.getCurrentPage()); // Initialize window state persistence (fire-and-forget) void initWindowState(); diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index e5f885a2..379821b8 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -4,7 +4,6 @@ import { createElement, appendChildren } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store"; -import { createSettingsOverlay } from "@components/SettingsOverlay"; import type { HealthStatus } from "@lib/profiles"; import { createServerPanel } from "./connect-page/ServerPanel"; import { createLoginForm } from "./connect-page/LoginForm"; @@ -14,7 +13,6 @@ import { loadCredential } from "@lib/credentials"; // Re-exports (public API must not change) // --------------------------------------------------------------------------- -export type { FormState, FormMode } from "./connect-page/LoginForm"; export type { SimpleProfile } from "./connect-page/ServerPanel"; import type { SimpleProfile } from "./connect-page/ServerPanel"; @@ -208,27 +206,52 @@ export function createConnectPage( // MountableComponent // --------------------------------------------------------------------------- - let settingsOverlay: ReturnType | null = null; + let settingsOverlay: ReturnType< + typeof import("@components/SettingsOverlay").createSettingsOverlay + > | null = null; + let settingsOverlayLoading = false; + let unsubSettingsOpen: (() => void) | null = null; + + // The settings overlay (whose tabs pull in the LiveKit stack) is created + // lazily on first open so it stays out of the startup path. Once created it + // manages its own show/hide off uiStore.settingsOpen. + function ensureSettingsOverlay(): void { + if (settingsOverlay !== null || settingsOverlayLoading) return; + settingsOverlayLoading = true; + void import("@components/SettingsOverlay").then(({ createSettingsOverlay }) => { + settingsOverlayLoading = false; + // The page may have been destroyed while the chunk loaded. + if (signal.aborted) return; + // Unauthenticated on the connect page — account actions are no-ops. + settingsOverlay = createSettingsOverlay({ + isAuthenticated: false, + onClose: () => closeSettings(), + onChangePassword: () => Promise.resolve(), + onUpdateProfile: () => Promise.resolve(), + onLogout: () => {}, + onDeleteAccount: () => Promise.resolve(), + onStatusChange: () => {}, + onEnableTotp: () => Promise.reject(new Error("Not authenticated")), + onConfirmTotp: () => Promise.reject(new Error("Not authenticated")), + onDisableTotp: () => Promise.reject(new Error("Not authenticated")), + }); + settingsOverlay.mount(root); + }); + } function mount(target: Element): void { container = target; const rootEl = buildRoot(); container.appendChild(rootEl); - // Mount settings overlay on the connect page (unauthenticated — account actions are no-ops) - settingsOverlay = createSettingsOverlay({ - isAuthenticated: false, - onClose: () => closeSettings(), - onChangePassword: () => Promise.resolve(), - onUpdateProfile: () => Promise.resolve(), - onLogout: () => {}, - onDeleteAccount: () => Promise.resolve(), - onStatusChange: () => {}, - onEnableTotp: () => Promise.reject(new Error("Not authenticated")), - onConfirmTotp: () => Promise.reject(new Error("Not authenticated")), - onDisableTotp: () => Promise.reject(new Error("Not authenticated")), - }); - settingsOverlay.mount(rootEl); + // Create the settings overlay the first time settings are opened. + unsubSettingsOpen = uiStore.subscribeSelector( + (s) => s.settingsOpen, + (settingsOpen) => { + if (settingsOpen) ensureSettingsOverlay(); + }, + ); + if (uiStore.getState().settingsOpen) ensureSettingsOverlay(); // Show any pending auth error (e.g. "already connected from another client") const pendingError = uiStore.getState().transientError; @@ -244,6 +267,8 @@ export function createConnectPage( function destroy(): void { // Abort all event listeners registered with the signal abortController.abort(); + unsubSettingsOpen?.(); + unsubSettingsOpen = null; settingsOverlay?.destroy?.(); settingsOverlay = null; diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 9d5f8c82..fd9a8c8c 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -19,6 +19,7 @@ import { logout } from "@lib/logout"; import { authStore, clearAuth, updateUser } from "@stores/auth.store"; import { closeSettings, uiStore } from "@stores/ui.store"; import { updatePresence } from "@stores/members.store"; +import { loadUserStatus } from "@lib/userStatus"; import { channelsStore, getActiveChannel } from "@stores/channels.store"; import { dmStore } from "@stores/dm.store"; import { voiceStore } from "@stores/voice.store"; @@ -33,6 +34,8 @@ import { } from "@lib/livekitSession"; import { setServerHost } from "@components/message-list/renderers"; import { createQuickSwitcherManager } from "./main-page/OverlayManagers"; +import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds"; +import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks"; import { createMessageController, createPendingDeleteManager } from "./main-page/MessageController"; import type { MessageController } from "./main-page/MessageController"; import { createReactionController } from "./main-page/ReactionController"; @@ -115,6 +118,23 @@ export function createMainPage(options: MainPageOptions): MountableComponent { return authStore.getState().user?.id ?? 0; } + /** + * Re-assert the status the user picked in settings. The server starts every + * session as "online", so without this a saved "Do Not Disturb" would show + * as selected in the panel while everyone else saw the user as online. + */ + function restoreSavedPresence(): void { + const status = loadUserStatus(); + if (status === "online") return; + const userId = getCurrentUserId(); + if (userId !== 0) { + updatePresence(userId, status); + } + if (limiters.presence.tryConsume()) { + ws.send({ type: "presence_update", payload: { status } }); + } + } + /** Resolve display name for a channel — for DMs, use recipient username from DM store. */ function resolveChannelName( channelId: number, @@ -205,6 +225,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { (s) => s.connectionStatus, (status) => { try { + if (status === "connected") restoreSavedPresence(); if (banner === null) return; applyConnectionStatus(banner, status); } catch (err) { @@ -218,6 +239,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // (status already "reconnecting") would otherwise never show the banner — // the whole retry cycle maps to the same 3-state value. applyConnectionStatus(banner, uiStore.getState().connectionStatus); + if (uiStore.getState().connectionStatus === "connected") restoreSavedPresence(); unsubscribers.push( ws.on("server_restart", (payload) => { @@ -358,6 +380,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent { const qsManager = createQuickSwitcherManager(() => root); unsubscribers.push(qsManager.attach()); + // The rest of the shortcuts listed on the settings Keybinds tab. + const voiceKeybindActions = createVoiceWidgetCallbacks(ws, limiters); + unsubscribers.push( + attachGlobalKeybinds({ + onSearch: () => chatAreaResult.searchCtrl.open(), + onToggleMute: () => voiceKeybindActions.onMuteToggle(), + onToggleDeafen: () => voiceKeybindActions.onDeafenToggle(), + onToggleCamera: () => voiceKeybindActions.onCameraToggle(), + onUploadFile: () => channelCtrl?.openFilePicker(), + // Don't fire app shortcuts while the settings panel is on top of them. + isSuspended: () => uiStore.getState().settingsOpen, + }), + ); + // Toast container toast = createToastContainer(); toast.mount(root); diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index e2b263fd..1bc71907 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -30,6 +30,7 @@ import type { ReactionController } from "./ReactionController"; import { updateChatHeaderForDm } from "./ChatHeader"; import type { ChatHeaderRefs } from "./ChatHeader"; import { dmStore } from "@stores/dm.store"; +import { canManageMessages } from "@lib/permissions"; import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store"; import { membersStore } from "@stores/members.store"; import { channelsStore } from "@stores/channels.store"; @@ -68,6 +69,8 @@ export interface ChannelController { readonly currentChannelId: number | null; /** Currently mounted message list (for scroll-to-message). */ readonly messageList: MessageListComponent | null; + /** Open the composer's attachment picker (Ctrl+U). No-op with no composer. */ + openFilePicker(): void; } // --------------------------------------------------------------------------- @@ -322,6 +325,23 @@ export function createChannelController(opts: ChannelControllerOptions): Channel channelType === "dm" ? (dmStore.getState().channels.find((c) => c.channelId === channelId)?.recipient.id ?? null) : null; + // Slow mode as affordance: after an accepted send the composer disables + // itself for the channel's cooldown with a live countdown, instead of + // taking a message the server will bounce with SLOW_MODE (UX spec §5, + // "do not drop the drafted message" — the draft stays in the textarea). + let slowModeUntil = 0; + let slowModeTicker: ReturnType | null = null; + + const stopSlowModeTicker = (): void => { + if (slowModeTicker !== null) { + clearInterval(slowModeTicker); + slowModeTicker = null; + } + }; + + const slowModeRemaining = (): number => + slowModeUntil === 0 ? 0 : Math.max(0, Math.ceil((slowModeUntil - Date.now()) / 1000)); + const computeComposerReason = (): string | null => { const status = uiStore.getState().connectionStatus; if (status === "reconnecting") return "Reconnecting…"; @@ -337,11 +357,52 @@ export function createChannelController(opts: ChannelControllerOptions): Channel ? "Only moderators can post in announcement channels" : "You don't have permission to send messages here"; } + const remaining = slowModeRemaining(); + if (remaining > 0) return `Slow mode — ${remaining}s`; return null; }; const refreshComposerState = (): void => { messageInput?.setDisabled(computeComposerReason()); }; + + /** + * Begin (or restart) the slow-mode cooldown for this channel. Moderators + * bypass slow mode server-side, so they never get gated here either. + */ + const startSlowMode = (seconds: number): void => { + if (seconds <= 0 || canManageMessages()) return; + slowModeUntil = Date.now() + seconds * 1000; + refreshComposerState(); + stopSlowModeTicker(); + slowModeTicker = setInterval(() => { + if (slowModeRemaining() <= 0) { + slowModeUntil = 0; + stopSlowModeTicker(); + } + refreshComposerState(); + }, 1000); + }; + composerGatingUnsubs.push(stopSlowModeTicker); + + // The server accepted a message — the next one is subject to the cooldown. + composerGatingUnsubs.push( + ws.on("chat_send_ok", () => { + const ch = channelsStore.getState().channels.get(channelId); + if (ch !== undefined && ch.id === channelsStore.getState().activeChannelId) { + startSlowMode(ch.slowMode); + } + }), + ); + // A refused send restarts the full window: the server's limiter is the + // authority on when the next one is allowed. + composerGatingUnsubs.push( + ws.on("error", (payload) => { + if (payload.code !== "SLOW_MODE") return; + const ch = channelsStore.getState().channels.get(channelId); + if (ch !== undefined) startSlowMode(ch.slowMode); + }), + ); + refreshComposerState(); composerGatingUnsubs.push( uiStore.subscribeSelector( @@ -407,6 +468,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel return { mountChannel, destroyChannel, + openFilePicker: () => messageInput?.openFilePicker(), get currentChannelId() { return _currentChannelId; }, diff --git a/Client/tauri-client/src/pages/main-page/GlobalKeybinds.ts b/Client/tauri-client/src/pages/main-page/GlobalKeybinds.ts new file mode 100644 index 00000000..e8a95c4d --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/GlobalKeybinds.ts @@ -0,0 +1,84 @@ +/** + * GlobalKeybinds — the app-wide shortcuts the settings Keybinds tab advertises. + * + * Quick Switcher (Ctrl+K) is owned by its own manager; everything else the + * Keybinds tab lists lives here so the panel and the behaviour can't drift. + * Voice actions no-op outside a voice channel rather than firing signalling + * messages into a session that doesn't exist. + */ + +import { createLogger } from "@lib/logger"; +import { voiceStore } from "@stores/voice.store"; + +const log = createLogger("global-keybinds"); + +export interface GlobalKeybindHandlers { + /** Ctrl+F — open the message search overlay. */ + readonly onSearch: () => void; + /** Ctrl+M — toggle microphone mute (voice only). */ + readonly onToggleMute: () => void; + /** Ctrl+D — toggle deafen (voice only). */ + readonly onToggleDeafen: () => void; + /** Ctrl+Shift+V — toggle the camera (voice only). */ + readonly onToggleCamera: () => void; + /** Ctrl+U — open the composer's attachment picker. */ + readonly onUploadFile: () => void; + /** Whether shortcuts should be ignored right now (e.g. settings overlay open). */ + readonly isSuspended?: () => boolean; +} + +/** True while the user is connected to a voice channel. */ +function inVoice(): boolean { + return voiceStore.getState().currentChannelId !== null; +} + +/** + * Register the shortcuts on `document`. Returns a detach function. + */ +export function attachGlobalKeybinds(handlers: GlobalKeybindHandlers): () => void { + const handler = (e: KeyboardEvent): void => { + if (!(e.ctrlKey || e.metaKey) || e.altKey) return; + if (handlers.isSuspended?.() === true) return; + + // `e.key` is layout-dependent and uppercases with Shift held — compare + // case-insensitively so Ctrl+Shift+V arrives as "V", not a missed "v". + const key = e.key.toLowerCase(); + + const run = (label: string, action: () => void): void => { + e.preventDefault(); + try { + action(); + } catch (err) { + log.error("Keybind handler failed", { key: label, error: String(err) }); + } + }; + + if (e.shiftKey) { + // Only Ctrl+Shift+V is claimed; other Shift combos fall through to the app. + if (key === "v" && inVoice()) run("toggle-camera", handlers.onToggleCamera); + return; + } + + switch (key) { + case "f": + run("search", handlers.onSearch); + break; + case "m": + if (inVoice()) run("toggle-mute", handlers.onToggleMute); + break; + case "d": + if (inVoice()) run("toggle-deafen", handlers.onToggleDeafen); + break; + case "u": + run("upload-file", handlers.onUploadFile); + break; + default: + break; + } + }; + + document.addEventListener("keydown", handler); + return () => { + document.removeEventListener("keydown", handler); + }; +} diff --git a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts index 3d7f8573..b79a6216 100644 --- a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts +++ b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts @@ -165,7 +165,12 @@ export function createInviteManagerController(opts: { } }, onCopyLink: (code: string) => { - void navigator.clipboard.writeText(code); + // No silent success: a copy the user can't see is indistinguishable + // from a clipboard permission failure. + void navigator.clipboard.writeText(code).then( + () => showToast("Invite code copied", "success"), + () => showToast("Couldn't copy the invite code", "error"), + ); }, onClose: close, onError: (message: string) => { diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts index 4519f2fc..9c23c5c6 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarArea.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -12,7 +12,6 @@ import type { ApiClient } from "@lib/api"; import type { RateLimiterSet } from "@lib/rate-limiter"; import type { ToastContainer } from "@components/Toast"; import { createChannelSidebar } from "@components/ChannelSidebar"; -import { createMemberList } from "@components/MemberList"; import { createDmSidebar } from "@components/DmSidebar"; import { createCreateChannelModal } from "@components/CreateChannelModal"; import { createEditChannelModal } from "@components/EditChannelModal"; @@ -22,6 +21,7 @@ import { createVoiceWidget } from "@components/VoiceWidget"; import { createQuickSwitchOverlay } from "@components/QuickSwitchOverlay"; import type { QuickSwitchProfile } from "@components/QuickSwitchOverlay"; import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./VoiceCallbacks"; +import { createSidebarMemberSection } from "./SidebarMemberSection"; import { createInviteManagerController } from "./OverlayManagers"; import { selectDmConversation, @@ -33,7 +33,7 @@ import { createSidebarDmSection } from "./SidebarDmSection"; import { uiStore, setSidebarMode, loadCollapsedCategories } from "@stores/ui.store"; import { authStore, clearAuth } from "@stores/auth.store"; import { membersStore, getOnlineMembers } from "@stores/members.store"; -import { channelsStore, setActiveChannel, getRoleIdByName } from "@stores/channels.store"; +import { channelsStore, setActiveChannel } from "@stores/channels.store"; import { dmStore, removeDmChannel } from "@stores/dm.store"; import { createProfileManager, createTauriBackend } from "@lib/profiles"; import type { ProfileManager } from "@lib/profiles"; @@ -495,141 +495,12 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { } // --- Member list (below DM section) --- - const memberListContainer = createElement("div", { - class: "sidebar-members-section", - "data-testid": "sidebar-members", - }); - - // Member header (styled like category headers) - const memberHeader = createElement("div", { class: "category sidebar-members-header" }); - const memberArrow = createElement("span", { class: "category-arrow" }, "\u25BC"); - const memberLabelEl = createElement("span", { class: "category-name" }, "MEMBERS"); - appendChildren(memberHeader, memberArrow, memberLabelEl); - memberListContainer.appendChild(memberHeader); - - // Resize handle - const resizeHandle = createElement("div", { class: "sidebar-resize-handle" }); - memberListContainer.appendChild(resizeHandle); - - // Restore saved height - const savedHeight = localStorage.getItem("owncord:member-list-height"); - if (savedHeight !== null) { - memberListContainer.style.height = `${savedHeight}px`; - } - - // Drag-to-resize logic - const resizeAbort = new AbortController(); - let isDragging = false; - let startY = 0; - let startHeight = 0; - - resizeHandle.addEventListener( - "mousedown", - (e: MouseEvent) => { - isDragging = true; - startY = e.clientY; - startHeight = memberListContainer.offsetHeight; - e.preventDefault(); - }, - { signal: resizeAbort.signal }, - ); - - document.addEventListener( - "mousemove", - (e: MouseEvent) => { - if (!isDragging) return; - const delta = startY - e.clientY; - const maxH = window.innerHeight * 0.65; - const newHeight = Math.max(80, Math.min(startHeight + delta, maxH)); - memberListContainer.style.height = `${newHeight}px`; - }, - { signal: resizeAbort.signal }, - ); - - document.addEventListener( - "mouseup", - () => { - if (!isDragging) return; - isDragging = false; - localStorage.setItem( - "owncord:member-list-height", - String(memberListContainer.offsetHeight), - ); - }, - { signal: resizeAbort.signal }, - ); - - channelModeUnsubs.push(() => { - resizeAbort.abort(); - }); - - // Restore collapsed state from localStorage - const savedCollapsed = localStorage.getItem("owncord:member-list-collapsed"); - let membersCollapsed = savedCollapsed === "true"; - const memberContent = createElement("div", { class: "sidebar-members-content" }); - - function applyMembersCollapsed(): void { - memberHeader.classList.toggle("collapsed", membersCollapsed); - memberArrow.textContent = membersCollapsed ? "\u25B6" : "\u25BC"; - memberContent.style.display = membersCollapsed ? "none" : ""; - resizeHandle.style.display = membersCollapsed ? "none" : ""; - if (membersCollapsed) { - memberListContainer.style.height = "auto"; - } else { - const h = localStorage.getItem("owncord:member-list-height"); - if (h !== null) { - memberListContainer.style.height = `${h}px`; - } else { - memberListContainer.style.height = ""; - } - } - } - - // Apply initial state - applyMembersCollapsed(); - - memberHeader.addEventListener("click", () => { - membersCollapsed = !membersCollapsed; - localStorage.setItem("owncord:member-list-collapsed", String(membersCollapsed)); - applyMembersCollapsed(); - }); - - const memberList = createMemberList({ - currentUserRole: authStore.getState().user?.role ?? "member", - onKick: async (userId, username) => { - try { - await api.adminKickMember(userId); - getToast()?.show(`Kicked ${username}`, "success"); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to kick member"; - getToast()?.show(msg, "error"); - } - }, - onBan: async (userId, username) => { - try { - await api.adminBanMember(userId); - getToast()?.show(`Banned ${username}`, "success"); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to ban member"; - getToast()?.show(msg, "error"); - } - }, - onChangeRole: async (userId, username, newRole) => { - const roleId = getRoleIdByName(newRole); - if (roleId === undefined) return; - try { - await api.adminChangeRole(userId, roleId); - getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success"); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to change role"; - getToast()?.show(msg, "error"); - } - }, - }); - memberList.mount(memberContent); - memberListContainer.appendChild(memberContent); - contentSlot.appendChild(memberListContainer); - channelModeExtras.push(memberList); + // Same wiring lives in SidebarMemberSection; this used to be a private + // copy of it, and a fix to one silently missed the other. + const memberSection = createSidebarMemberSection({ api, getToast }); + contentSlot.appendChild(memberSection.element); + channelModeExtras.push(memberSection.memberListComponent); + channelModeUnsubs.push(memberSection.destroy); } else { const dmSidebar = buildDmSidebar(); dmSidebar.mount(innerSlot); diff --git a/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts b/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts index 4ef63d6d..d5328fcb 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts @@ -75,6 +75,7 @@ export function addDmToChannelsStore(dmChannel: DmChannel): void { // Channel-level permission is always true for DMs; block state is layered on // top by the composer via blocks.store (see ChannelController), not canSend. canSend: true, + slowMode: 0, }; channelsStore.setState((prev) => { const next = new Map(prev.channels); diff --git a/Client/tauri-client/src/pages/main-page/SidebarMemberSection.ts b/Client/tauri-client/src/pages/main-page/SidebarMemberSection.ts index 148e4c9b..09ee5617 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarMemberSection.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarMemberSection.ts @@ -156,9 +156,9 @@ export function createSidebarMemberSection( getToast()?.show(msg, "error"); } }, - onBan: async (userId, username) => { + onBan: async (userId, username, reason) => { try { - await api.adminBanMember(userId); + await api.adminBanMember(userId, reason); getToast()?.show(`Banned ${username}`, "success"); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to ban member"; @@ -167,7 +167,11 @@ export function createSidebarMemberSection( }, onChangeRole: async (userId, username, newRole) => { const roleId = getRoleIdByName(newRole); - if (roleId === undefined) return; + if (roleId === undefined) { + // No silent failures: the role vanished from the server's list. + getToast()?.show(`Unknown role "${newRole}" — try reconnecting`, "error"); + return; + } try { await api.adminChangeRole(userId, roleId); getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success"); diff --git a/Client/tauri-client/src/stores/auth.store.ts b/Client/tauri-client/src/stores/auth.store.ts index 105bbcbe..fb069b04 100644 --- a/Client/tauri-client/src/stores/auth.store.ts +++ b/Client/tauri-client/src/stores/auth.store.ts @@ -5,9 +5,11 @@ import { createStore } from "@lib/store"; import type { UserWithRole } from "@lib/types"; -import { resetVoiceStore } from "@stores/voice.store"; -import { leaveVoice } from "@lib/livekitSession"; +import { resetVoiceStore, voiceStore } from "@stores/voice.store"; import { cleanupNotificationAudio } from "@lib/notifications"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("auth.store"); export interface AuthState { readonly token: string | null; @@ -42,7 +44,18 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m * session (WebRTC, AudioContext, streams) and clears voice store state. * Safe to call even if no voice session is active — leaveVoice is idempotent. */ export function clearAuth(): void { - leaveVoice(false); + // 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 + // actually a voice session to leave — otherwise a text-only user who never + // joined voice would pull in the whole LiveKit SDK on every logout/401. + // 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(); + 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(); cleanupNotificationAudio(); authStore.setState(() => ({ ...INITIAL_STATE })); diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts index 3135e922..68e3d2c6 100644 --- a/Client/tauri-client/src/stores/channels.store.ts +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -22,6 +22,8 @@ export interface Channel { readonly lastMessageId: number | null; /** Whether the current user may post here (drives the composer affordance). */ readonly canSend: boolean; + /** Per-channel cooldown in seconds (0 = off). Drives the composer countdown. */ + readonly slowMode: number; } export interface ChannelsState { @@ -53,6 +55,7 @@ export function setChannels(channels: readonly ReadyChannel[]): void { // The current server always sends can_send; older servers omit it, in // which case we default permissive (no gating) rather than guessing. canSend: ch.can_send ?? true, + slowMode: ch.slow_mode ?? 0, }); } channelsStore.setState((prev) => ({ @@ -88,6 +91,7 @@ export function addChannel(channel: ChannelCreatePayload): void { // Broadcasts carry no per-user data; default permissive. The next ready // payload delivers the authoritative can_send. Server enforces regardless. canSend: true, + slowMode: channel.slow_mode ?? 0, }); return { ...prev, channels: next }; }); @@ -104,6 +108,7 @@ export function updateChannel(update: ChannelUpdatePayload): void { ...existing, ...(update.name !== undefined ? { name: update.name } : {}), ...(update.position !== undefined ? { position: update.position } : {}), + ...(update.slow_mode !== undefined ? { slowMode: update.slow_mode } : {}), }; const next = new Map(prev.channels); next.set(update.id, updated); diff --git a/Client/tauri-client/src/stores/members.store.ts b/Client/tauri-client/src/stores/members.store.ts index 695f4785..6a8fbf86 100644 --- a/Client/tauri-client/src/stores/members.store.ts +++ b/Client/tauri-client/src/stores/members.store.ts @@ -21,11 +21,18 @@ export interface Member { export interface MembersState { readonly members: ReadonlyMap; readonly typingUsers: ReadonlyMap>; // channelId -> Set + /** Monotonic counter bumped only when membership or a member's role changes + * (setMembers/addMember/removeMember/updateMemberRole). Subscribers that + * only care about role composition (e.g. MessageList role colors) select + * this instead of rebuilding a role map on every presence/typing update. + * Optional only so the many inline test fixtures need not restate it. */ + readonly roleRevision?: number; } const INITIAL_STATE: MembersState = { members: new Map(), typingUsers: new Map(), + roleRevision: 0, }; export const membersStore = createStore(INITIAL_STATE); @@ -57,9 +64,10 @@ export function setMembers(members: readonly ReadyMember[]): void { clearTimeout(timer); } typingTimers.clear(); - membersStore.setState(() => ({ + membersStore.setState((prev) => ({ members: map, typingUsers: new Map(), + roleRevision: (prev.roleRevision ?? 0) + 1, })); } @@ -75,7 +83,7 @@ export function addMember(payload: MemberJoinPayload): void { status: "online", identityPublicKey: payload.user.identity_public_key ?? null, }); - return { ...prev, members: next }; + return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 }; }); } @@ -84,7 +92,7 @@ export function removeMember(userId: number): void { membersStore.setState((prev) => { const next = new Map(prev.members); next.delete(userId); - return { ...prev, members: next }; + return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 }; }); } @@ -95,7 +103,7 @@ export function updateMemberRole(userId: number, role: string): void { if (!existing) return prev; const next = new Map(prev.members); next.set(userId, { ...existing, role }); - return { ...prev, members: next }; + return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 }; }); } diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 28f47028..62f439af 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -1476,6 +1476,33 @@ .context-menu-item.danger:hover { background: var(--red); color: white; } .context-menu-sep { height: 1px; background: var(--border); margin: 4px 0; } +/* AdminActions (member/channel menus) uses BEM names — these had no rules at + all, so those menus rendered unstyled: no hover, no danger colour, and a + "submenu" that pushed the menu open instead of flying out. */ +.context-menu__item { + position: relative; + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; color: var(--text-normal); + background: transparent; width: 100%; text-align: left; + transition: background 0.1s ease, color 0.1s ease; +} +.context-menu__item:hover { background: var(--accent); color: white; } +.context-menu__item--active { color: var(--accent); font-weight: 600; } +.context-menu__item--active:hover { color: white; } +.context-menu__item--danger { color: var(--red); } +.context-menu__item--danger:hover { background: var(--red); color: white; } +.context-menu__item--pending { opacity: 0.7; cursor: default; pointer-events: none; } +.context-menu__separator { height: 1px; background: var(--border); margin: 4px 0; } +.context-menu__submenu { + position: absolute; left: 100%; top: 0; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 4px; + box-shadow: 0 8px 24px rgba(0,0,0,.5); + min-width: 140px; z-index: 1; +} +.context-menu__reason { display: flex; flex-direction: column; gap: 6px; } + /* ── Toast Notification ── */ .toast-container { position: fixed; bottom: 24px; left: 50%; @@ -2413,6 +2440,15 @@ .invite-item__revoke:hover { background: rgba(242, 63, 67, 0.15); } +.invite-item__revoke--confirming { + background: rgba(242, 63, 67, 0.2); + font-weight: 600; +} +.invite-item__revoke:disabled, +.invite-manager__create:disabled { + opacity: 0.6; + cursor: default; +} .invite-item__meta { font-size: 12px; color: var(--text-muted); diff --git a/Client/tauri-client/tests/e2e/helpers.ts b/Client/tauri-client/tests/e2e/helpers.ts index 9da61c42..d9063a40 100644 --- a/Client/tauri-client/tests/e2e/helpers.ts +++ b/Client/tauri-client/tests/e2e/helpers.ts @@ -198,9 +198,16 @@ export const MOCK_MESSAGES_RICH = { has_more: true, }; +// Remote users only — the ready payload must never claim the LOCAL user +// (id 1) is in a voice channel: the dispatcher treats "self in +// ready.voice_states while voiceStatus is idle" as stale state from a +// reload and immediately sends voice_leave + clears the local store +// (dispatcher.ts stale-voice cleanup), which would hide the widget again. +// Tests that need the widget visible must join via the real click path +// (see joinVoiceChannelByName). export const MOCK_VOICE_STATE = [ - { user_id: 1, channel_id: 10, muted: false, deafened: false }, { user_id: 2, channel_id: 10, muted: true, deafened: false }, + { user_id: 3, channel_id: 10, muted: false, deafened: false }, ]; export const MOCK_PINNED_MESSAGES = { @@ -350,17 +357,21 @@ export function voiceWsHandlers(): Array<{ type: string; handler: string }> { handler: ` var p = parsed.payload; setTimeout(function() { + // Full VoiceStatePayload shape — the server always sends username + // (and the flag fields); the sidebar renders user.username directly, + // so an omitted username breaks the voice-user list render. __tauriEmitEvent("ws-message", JSON.stringify({ type: "voice_state", - payload: { user_id: 1, channel_id: p.channel_id, muted: false, deafened: false } + 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-livekit-token", url: "ws://localhost:7880", channel_id: p.channel_id, direct_url: "" } - })); - }, 100); + // Deliberately NO voice_token reply: a token makes the client start a + // real LiveKit session, which in the browser mock deterministically + // self-destructs (E2EE key exchange times out after ~15s, and + // Room.connect to the fake port fails after ~3 retries), tearing the + // widget down mid-test. These web tests validate the WS/UI layer only + // (see voice-lifecycle.spec.ts header); real LiveKit is covered by the + // native suite. `, }, { @@ -607,6 +618,18 @@ export function buildTauriMockScript(opts: { } if (cmd === "ws_disconnect") return; + // ---- HTTP TOFU proxy ---- + // api.ts routes all REST calls through the Rust loopback proxy: + // baseUrl() awaits start_http_proxy and builds + // http://127.0.0.1:{port}/api/v1/... — if this returns null (the + // unhandled-command fallback), the URL gets a literal "null" port and + // Request construction throws before the plugin:http mock above is + // ever consulted, failing every login. Any numeric port works: the + // transport is still plugin:http|fetch and route matching is + // substring-based, so the fake origin never has to be listened on. + if (cmd === "start_http_proxy") return 45123; + if (cmd === "stop_http_proxy") return; + // ---- LiveKit proxy ---- if (cmd === "start_livekit_proxy") return { port: 7880 }; if (cmd === "stop_livekit_proxy") return; @@ -620,6 +643,14 @@ export function buildTauriMockScript(opts: { // ---- Certs ---- if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null; + if (cmd === "accept_cert_fingerprint") return null; + + // ---- 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. + 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; // ---- Window/webview plugin stubs ---- if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null; @@ -889,6 +920,22 @@ export async function navigateToMainPageReady(page: Page): Promise { await waitForWsReady(page); } +/** + * Join a voice channel through the real click path and wait for the voice + * widget to become visible. This is the only supported way for tests to get + * the local user into voice: pre-seeding the ready payload with user 1 no + * longer works (the dispatcher's stale-voice cleanup immediately leaves). + */ +export async function joinVoiceChannelByName( + page: Page, + channelName = "Voice Chat", +): Promise { + await page.locator(".channel-item.voice", { hasText: channelName }).click(); + await expect(page.locator("[data-testid='voice-widget']")).toHaveClass(/visible/, { + timeout: 10_000, + }); +} + /** * Emit a WS message and wait for a DOM change to confirm it was processed. * Prevents flakiness from tests asserting before the message handler runs. diff --git a/Client/tauri-client/tests/e2e/toast.spec.ts b/Client/tauri-client/tests/e2e/toast.spec.ts index 35d76739..6552a89e 100644 --- a/Client/tauri-client/tests/e2e/toast.spec.ts +++ b/Client/tauri-client/tests/e2e/toast.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import { mockTauriFullSession, + mockTauriFullSessionWithMessagesAndEcho, mockTauriFullSessionWithFailingMessages, navigateToMainPage, emitWsEvent, @@ -11,35 +12,37 @@ import { // --------------------------------------------------------------------------- test.describe("Toast Notifications", () => { - test("toast appears when message load fails (500 response)", async ({ page }) => { + // Message-load failure no longer toasts: the app renders an inline + // section error + Retry in the message region instead (UX spec §2 — a + // toast would vanish and leave the region silently empty). See + // MessageController.loadMessages. + test("message load failure (500) shows inline error with Retry", async ({ page }) => { await mockTauriFullSessionWithFailingMessages(page); await page.goto("/"); await navigateToMainPage(page); - // The toast container should exist in the DOM - const toastContainer = page.locator("[data-testid='toast-container']"); - await expect(toastContainer).toBeAttached({ timeout: 5_000 }); + const loadError = page.locator(".messages-load-error"); + await expect(loadError).toBeVisible({ timeout: 10_000 }); + await expect(loadError).toContainText(/couldn't load messages/i); - // An error toast should appear because /messages returns 500 - const toast = page.locator("[data-testid='toast']"); - await expect(toast.first()).toBeVisible({ timeout: 10_000 }); - - // Toast should have the error type class - await expect(toast.first()).toHaveClass(/toast-error/); - - // Toast text should mention failure - const text = await toast.first().textContent(); - expect(text).toMatch(/fail/i); + const retryBtn = page.locator("[data-testid='messages-retry']"); + await expect(retryBtn).toBeVisible(); }); test("toast auto-dismisses after timeout", async ({ page }) => { - await mockTauriFullSessionWithFailingMessages(page); + // Trigger a real toast through the delete-confirmation flow: the first + // click on a message's Delete action shows the info toast + // "Click delete again to confirm". + await mockTauriFullSessionWithMessagesAndEcho(page); await page.goto("/"); await navigateToMainPage(page); - // Wait for the error toast to appear + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-delete-101']").click(); + const toast = page.locator("[data-testid='toast']"); - await expect(toast.first()).toBeVisible({ timeout: 10_000 }); + await expect(toast.first()).toBeVisible({ timeout: 5_000 }); // Default duration is 5000ms; toast gets .show removed then transitions out. // Wait for toast to disappear (5s timeout + 400ms fallback removal) diff --git a/Client/tauri-client/tests/e2e/voice-channel.spec.ts b/Client/tauri-client/tests/e2e/voice-channel.spec.ts index 97c6cc21..d4664401 100644 --- a/Client/tauri-client/tests/e2e/voice-channel.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-channel.spec.ts @@ -4,7 +4,11 @@ * VoiceWidget shows connected users when in a voice channel. */ import { test, expect } from "@playwright/test"; -import { mockTauriFullSessionWithVoice, navigateToMainPage } from "./helpers"; +import { + mockTauriFullSessionWithVoice, + navigateToMainPage, + joinVoiceChannelByName, +} from "./helpers"; test.describe("Voice Channel Items", () => { test.beforeEach(async ({ page }) => { @@ -31,16 +35,20 @@ test.describe("Voice Channel Items", () => { }); test("voice widget shows when connected", async ({ page }) => { - // VoiceWidget should be visible (mock connects user to voice channel) + // Join through the real click path — the ready payload can no longer + // pre-connect the local user (stale-voice cleanup would leave again). + await joinVoiceChannelByName(page); const widget = page.locator(".voice-widget.visible"); await expect(widget).toBeVisible({ timeout: 5000 }); }); test("voice widget shows connected users", async ({ page }) => { - // Mock voice state has 2 users in channel 10 (Voice Chat) + // Two remote users (2, 3) are in channel 10 from the ready payload; + // joining adds the local user for a total of three. + await joinVoiceChannelByName(page); const voiceUsers = page.locator(".voice-user-item"); await expect(voiceUsers.first()).toBeVisible({ timeout: 5000 }); - await expect(voiceUsers).toHaveCount(2); + await expect(voiceUsers).toHaveCount(3); }); test("voice user item shows avatar", async ({ page }) => { @@ -55,16 +63,19 @@ test.describe("Voice Channel Items", () => { }); test("voice widget shows channel name header", async ({ page }) => { + await joinVoiceChannelByName(page); const channelName = page.locator(".vw-channel"); await expect(channelName).toContainText("Voice Chat"); }); test("voice widget has disconnect control", async ({ page }) => { + await joinVoiceChannelByName(page); const disconnectBtn = page.locator("button[aria-label='Disconnect']"); await expect(disconnectBtn).toBeVisible({ timeout: 5000 }); }); test("mute button toggles active state on click", async ({ page }) => { + await joinVoiceChannelByName(page); const controls = page.locator(".vw-controls"); await expect(controls).toBeVisible({ timeout: 5000 }); @@ -78,6 +89,7 @@ test.describe("Voice Channel Items", () => { }); test("deafen button toggles active state on click", async ({ page }) => { + await joinVoiceChannelByName(page); const controls = page.locator(".vw-controls"); await expect(controls).toBeVisible({ timeout: 5000 }); @@ -90,6 +102,7 @@ test.describe("Voice Channel Items", () => { }); test("all five voice control buttons are present", async ({ page }) => { + await joinVoiceChannelByName(page); const controls = page.locator(".vw-controls"); await expect(controls).toBeVisible({ timeout: 5000 }); diff --git a/Client/tauri-client/tests/e2e/voice-lifecycle.spec.ts b/Client/tauri-client/tests/e2e/voice-lifecycle.spec.ts index 25a02954..76e223ab 100644 --- a/Client/tauri-client/tests/e2e/voice-lifecycle.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-lifecycle.spec.ts @@ -16,6 +16,7 @@ import { mockTauriFullSessionWithVoice, mockTauriFullSessionWithVoiceFailure, navigateToMainPageReady, + joinVoiceChannelByName, emitWsMessage, } from "./helpers"; @@ -27,7 +28,7 @@ test.describe("Voice lifecycle", () => { }); test("shows voice users in voice channel sidebar", async ({ page }) => { - // MOCK_VOICE_STATE has users 1 and 2 in channel 10 ("Voice Chat") + // MOCK_VOICE_STATE has remote users 2 and 3 in channel 10 ("Voice Chat") const voiceChannel = page.locator(".channel-item", { hasText: "Voice Chat" }); await expect(voiceChannel).toBeVisible(); @@ -72,12 +73,12 @@ test.describe("Voice lifecycle", () => { // Wait for voice users to render await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 }); - // Emit speakers event — user 1 is speaking + // Emit speakers event — user 3 (in channel 10 per the ready payload) speaks await emitWsMessage(page, { type: "voice_speakers", payload: { channel_id: 10, - speakers: [1], + speakers: [3], }, }); @@ -92,7 +93,7 @@ test.describe("Voice lifecycle", () => { // User starts speaking await emitWsMessage(page, { type: "voice_speakers", - payload: { channel_id: 10, speakers: [1] }, + payload: { channel_id: 10, speakers: [3] }, }); await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 }); @@ -143,6 +144,7 @@ test.describe("Voice widget", () => { }); test("voice widget stats pane toggles on signal click", async ({ page }) => { + await joinVoiceChannelByName(page); const signal = page.locator(".vw-signal"); const statsPane = page.locator(".vw-stats"); @@ -160,9 +162,11 @@ test.describe("Voice widget", () => { }); test.describe("Voice WS flow", () => { - // MOCK_VOICE_STATE puts user 1 in channel 10 ("Voice Chat") during the - // ready payload, so the widget is ALREADY visible when tests start. - // Clicking "Voice Chat" toggles (leaves), clicking "Music" joins channel 11. + // The local user starts OUTSIDE voice: pre-seeding ready.voice_states with + // user 1 no longer works — the dispatcher's stale-voice cleanup would send + // voice_leave and clear the store immediately. Tests join via the real + // click path (joinVoiceChannelByName) and the widget shows because + // joinVoiceChannel() sets currentChannelId synchronously on click. test.beforeEach(async ({ page }) => { await mockTauriFullSessionWithVoice(page); @@ -170,30 +174,29 @@ test.describe("Voice WS flow", () => { await navigateToMainPageReady(page); }); - // 1. Voice join flow — leave first, then join a different channel. + // 1. Voice join flow — join, leave, then join a different channel. test("joining a voice channel shows the widget", async ({ page }) => { const widget = page.locator("[data-testid='voice-widget']"); - // Widget is already visible (user 1 in channel 10 from MOCK_VOICE_STATE) - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); + // Not in voice at start + await expect(widget).not.toHaveClass(/visible/); - // Leave current channel via Disconnect + // Join "Voice Chat" (channel 10) + await joinVoiceChannelByName(page, "Voice Chat"); + + // Leave via Disconnect const disconnectBtn = widget.locator("button[aria-label='Disconnect']"); await disconnectBtn.click(); await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 }); - // Join "Music" (channel 11, user is NOT in it) - const musicChannel = page.locator(".channel-item.voice", { hasText: "Music" }); - await musicChannel.click(); - - // joinVoiceChannel sets currentChannelId immediately → widget gets .visible - await expect(widget).toHaveClass(/visible/, { timeout: 10_000 }); + // Join "Music" (channel 11) + await joinVoiceChannelByName(page, "Music"); }); - // 2. Voice leave flow — widget is already visible; clicking Disconnect hides it. + // 2. Voice leave flow — join, then clicking Disconnect hides the widget. test("clicking disconnect hides voice widget", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); const disconnectBtn = widget.locator("button[aria-label='Disconnect']"); await disconnectBtn.click(); @@ -207,7 +210,7 @@ test.describe("Voice WS flow", () => { await emitWsMessage(page, { type: "voice_speakers", - payload: { channel_id: 10, speakers: [1] }, + payload: { channel_id: 10, speakers: [3] }, }); await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 }); @@ -216,8 +219,8 @@ test.describe("Voice WS flow", () => { // 4. Permission recovery button — grant mic button appears when // listenOnly is true (display toggled via voice store subscription). test("grant mic button appears in listen-only mode", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); // Set listen-only mode by manipulating the DOM directly (store isn't // exposed on window; listenOnly is set by livekitSession on mic failure). @@ -250,8 +253,8 @@ test.describe("Voice WS flow", () => { // 6. Connection quality warning — stats pane auto-expands on quality degradation. test("quality degradation auto-expands stats pane", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); const statsPane = page.locator(".vw-stats"); await expect(statsPane).not.toHaveClass(/visible/); @@ -268,8 +271,8 @@ test.describe("Voice WS flow", () => { // 7. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class. test("mute and deafen buttons toggle state", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); const muteBtn = widget.locator("button[aria-label='Mute']"); await expect(muteBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5000 }); @@ -284,11 +287,10 @@ test.describe("Voice WS flow", () => { await expect(deafenBtn).toHaveClass(/active-ctrl/); }); - // 8. Voice timer — joinedAt is set during ready payload processing, - // so the timer is already running when the test starts. + // 8. Voice timer — joinedAt is set by joinVoiceChannel() on click. test("voice timer shows elapsed time", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); const timer = widget.locator(".vw-timer"); await expect(timer).toBeVisible({ timeout: 5000 }); @@ -297,8 +299,8 @@ test.describe("Voice WS flow", () => { // 9. Token refresh — emitting a new voice_token doesn't disconnect. test("token refresh does not disconnect session", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); await emitWsMessage(page, { type: "voice_token", @@ -321,9 +323,9 @@ test.describe("Voice WS flow", () => { await emitWsMessage(page, { type: "voice_state", payload: { - user_id: 1, + user_id: 3, channel_id: 10, - username: "testuser", + username: "member1", muted: false, deafened: false, speaking: false, @@ -336,26 +338,24 @@ test.describe("Voice WS flow", () => { await expect(cameraIndicator).toBeVisible({ timeout: 5000 }); }); - // 11. Re-join after leave — leave via Disconnect, then re-join. + // 11. Re-join after leave — join, leave via Disconnect, then re-join. test("can rejoin voice channel after leaving", async ({ page }) => { + await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); // Leave voice const disconnectBtn = widget.locator("button[aria-label='Disconnect']"); await disconnectBtn.click(); await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 }); - // Re-join by clicking "Voice Chat" (now user is NOT in it) - const voiceChannel = page.locator(".channel-item.voice", { hasText: "Voice Chat" }); - await voiceChannel.click(); - await expect(widget).toHaveClass(/visible/, { timeout: 10_000 }); + // Re-join + await joinVoiceChannelByName(page, "Voice Chat"); }); - // 12. Channel switch — already in Voice Chat, click Music to switch. + // 12. Channel switch — join Voice Chat, click Music to switch. test("switching voice channels updates channel name", async ({ page }) => { + await joinVoiceChannelByName(page, "Voice Chat"); const widget = page.locator("[data-testid='voice-widget']"); - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); // Verify initial channel name await expect(widget.locator(".vw-channel")).toHaveText("Voice Chat", { timeout: 5000 }); @@ -378,26 +378,19 @@ test.describe("Voice WS flow — failure", () => { await navigateToMainPageReady(page); }); - // 13. Voice join failure — leave first (user starts in channel 10), - // then join Music which triggers the failure handler. + // 13. Voice join failure — join Music, which triggers the failure handler. test("voice join failure does not crash and disconnect still works", async ({ page }) => { const widget = page.locator("[data-testid='voice-widget']"); + await expect(widget).not.toHaveClass(/visible/); - // User starts in channel 10 from MOCK_VOICE_STATE — leave first - await expect(widget).toHaveClass(/visible/, { timeout: 5_000 }); - const disconnectBtn = widget.locator("button[aria-label='Disconnect']"); - await disconnectBtn.click(); - await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 }); - - // Now join Music — the failure handler will respond with an error - const musicChannel = page.locator(".channel-item.voice", { hasText: "Music" }); - await musicChannel.click(); - - // joinVoiceChannel is called synchronously, so the widget shows immediately - await expect(widget).toHaveClass(/visible/, { timeout: 10_000 }); + // Join Music — the failure handler responds with a VOICE_JOIN_FAILED error + // event; joinVoiceChannel is called synchronously on click, so the widget + // shows immediately regardless. + await joinVoiceChannelByName(page, "Music"); // Wait for the error event to be processed — verify app is still functional // by checking the disconnect button remains clickable + const disconnectBtn = widget.locator("button[aria-label='Disconnect']"); await expect(disconnectBtn).toBeEnabled({ timeout: 5_000 }); await disconnectBtn.click(); await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 }); diff --git a/Client/tauri-client/tests/e2e/voice-widget.spec.ts b/Client/tauri-client/tests/e2e/voice-widget.spec.ts index 5d8b0117..e6cf1bdc 100644 --- a/Client/tauri-client/tests/e2e/voice-widget.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-widget.spec.ts @@ -87,11 +87,12 @@ test.describe("Voice Widget", () => { const usersBefore = await page.locator(".voice-user-item").count(); - // Another user joins the voice channel + // Another user joins the voice channel (id 4 — NOT already in + // MOCK_VOICE_STATE, so this is a genuine join, not an in-place update) await emitWsMessage(page, { type: "voice_state", payload: { - user_id: 3, + user_id: 4, username: "newvoiceuser", channel_id: 10, muted: false, diff --git a/Client/tauri-client/tests/unit/admin-actions.test.ts b/Client/tauri-client/tests/unit/admin-actions.test.ts index 29c654e2..d76119c4 100644 --- a/Client/tauri-client/tests/unit/admin-actions.test.ts +++ b/Client/tauri-client/tests/unit/admin-actions.test.ts @@ -95,7 +95,7 @@ describe("AdminActions", () => { result.destroy(); }); - it("Ban requires double-click confirmation", () => { + it("Ban asks for a reason before it fires", () => { const onBan = vi.fn(async () => {}); const { result } = makeMenu({ onBan }); @@ -105,13 +105,97 @@ describe("AdminActions", () => { ) as HTMLDivElement; banItem.click(); - expect(banItem.textContent).toBe("Are you sure?"); + expect(onBan).not.toHaveBeenCalled(); - banItem.click(); - expect(onBan).toHaveBeenCalledOnce(); + const reasonInput = result.element.querySelector( + "[data-testid='ban-reason-input']", + ) as HTMLInputElement; + expect(reasonInput).not.toBeNull(); + reasonInput.value = " spamming "; + + const confirm = result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement; + confirm.click(); + + // The server stores and displays the reason, so it's trimmed, not raw. + expect(onBan).toHaveBeenCalledWith("spamming"); result.destroy(); }); + it("Ban sends an empty reason when none is typed", () => { + const onBan = vi.fn(async () => {}); + const { result } = makeMenu({ onBan }); + + const banItem = Array.from( + result.element.querySelectorAll(".context-menu__item--danger"), + ).find((i) => i.textContent === "Ban") as HTMLDivElement; + banItem.click(); + (result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement).click(); + + expect(onBan).toHaveBeenCalledWith(""); + result.destroy(); + }); + + it("Ban ignores a second click while the request is in flight", () => { + let release: (() => void) | null = null; + const onBan = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const { result } = makeMenu({ onBan }); + + const banItem = Array.from( + result.element.querySelectorAll(".context-menu__item--danger"), + ).find((i) => i.textContent === "Ban") as HTMLDivElement; + banItem.click(); + const confirm = result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement; + confirm.click(); + expect(confirm.textContent).toBe("Banning..."); + confirm.click(); + + expect(onBan).toHaveBeenCalledTimes(1); + release!(); + result.destroy(); + }); + + it("Kick shows an in-flight state and disarms after a pause", async () => { + vi.useFakeTimers(); + try { + let release: (() => void) | null = null; + const onKick = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const { result } = makeMenu({ onKick }); + const kickItem = Array.from( + result.element.querySelectorAll(".context-menu__item--danger"), + ).find((i) => i.textContent === "Kick") as HTMLDivElement; + + // Armed, then left alone — a stray later click must not kick anyone. + kickItem.click(); + expect(kickItem.textContent).toBe("Are you sure?"); + vi.advanceTimersByTime(5000); + expect(kickItem.textContent).toBe("Kick"); + kickItem.click(); + expect(onKick).not.toHaveBeenCalled(); + + kickItem.click(); + expect(onKick).toHaveBeenCalledOnce(); + expect(kickItem.textContent).toBe("Kicking..."); + + release!(); + await vi.waitFor(() => { + expect(kickItem.textContent).toBe("Kick"); + }); + result.destroy(); + } finally { + vi.useRealTimers(); + } + }); + it("renders separator between role and danger items", () => { const { result } = makeMenu(); const separator = result.element.querySelector(".context-menu__separator"); diff --git a/Client/tauri-client/tests/unit/advanced-tab.test.ts b/Client/tauri-client/tests/unit/advanced-tab.test.ts index 625a1d60..6c6d98be 100644 --- a/Client/tauri-client/tests/unit/advanced-tab.test.ts +++ b/Client/tauri-client/tests/unit/advanced-tab.test.ts @@ -360,18 +360,14 @@ describe("AdvancedTab — Toggles & Structure", () => { expect(toggle.getAttribute("aria-checked")).toBe("false"); }); - it("renders Hardware Acceleration toggle defaulting to on", () => { + it("does not offer a Hardware Acceleration toggle that nothing honours", () => { const section = buildAdvancedTab(ac.signal); container.appendChild(section); - const rows = container.querySelectorAll(".setting-row"); - const hwRow = rows[1]!; - const label = hwRow.querySelector(".setting-label")!; - expect(label.textContent).toBe("Hardware Acceleration"); - - const toggle = hwRow.querySelector(".toggle")!; - expect(toggle.classList.contains("on")).toBe(true); - expect(toggle.getAttribute("aria-checked")).toBe("true"); + const labels = Array.from(container.querySelectorAll(".setting-label")).map( + (l) => l.textContent, + ); + expect(labels).not.toContain("Hardware Acceleration"); }); it("toggles Developer Mode on and persists to localStorage", () => { diff --git a/Client/tauri-client/tests/unit/appearance-tab.test.ts b/Client/tauri-client/tests/unit/appearance-tab.test.ts index 0a1947f7..b4ac50ce 100644 --- a/Client/tauri-client/tests/unit/appearance-tab.test.ts +++ b/Client/tauri-client/tests/unit/appearance-tab.test.ts @@ -170,6 +170,31 @@ describe("AppearanceTab — Accessibility", () => { expect(hexInput.placeholder).toBe("5865f2"); }); + it("keeps a saved accent applied after switching themes", () => { + // applyThemeByName strips every inline custom property from , so the + // accent override has to be re-applied or the theme's own --accent wins. + mockApplyThemeByName.mockImplementation(() => { + const style = document.body.style; + for (let i = style.length - 1; i >= 0; i--) { + const prop = style.item(i); + if (prop.startsWith("--")) style.removeProperty(prop); + } + }); + + const section = buildAppearanceTab(ac.signal); + container.appendChild(section); + + const swatches = container.querySelectorAll(".accent-swatch"); + (swatches[1] as HTMLElement).click(); // #57f287 + expect(document.body.style.getPropertyValue("--accent")).toBe("#57f287"); + + const tiles = container.querySelectorAll(".theme-opt"); + (tiles[0] as HTMLElement).click(); // switch to "dark" + + expect(document.body.style.getPropertyValue("--accent")).toBe("#57f287"); + expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#57f287"); + }); + it("restores a custom active theme without forcing a built-in tile active", () => { mockGetActiveThemeName.mockReturnValue("custom-sunrise"); diff --git a/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts new file mode 100644 index 00000000..c4722800 --- /dev/null +++ b/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts @@ -0,0 +1,1015 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), + mockSavePref: vi.fn(), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: (key: string, val: unknown) => mockSavePref(key, val), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/noise-suppression", () => ({ + createRNNoiseProcessor: vi.fn(), +})); + +vi.mock("livekit-client", () => ({ + Track: { + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, + }, +})); + +import { AudioPipeline } from "../../src/lib/audioPipeline"; +import { createRNNoiseProcessor } from "../../src/lib/noise-suppression"; + +describe("AudioPipeline", () => { + let pipeline: AudioPipeline; + + beforeEach(() => { + vi.clearAllMocks(); + pipeline = new AudioPipeline(); + }); + + describe("initial state", () => { + it("is not active by default", () => { + expect(pipeline.isActive).toBe(false); + }); + + it("has null gainValue when inactive", () => { + expect(pipeline.gainValue).toBeNull(); + }); + + it("has null ctxState when inactive", () => { + expect(pipeline.ctxState).toBeNull(); + }); + + it("is not VAD gated by default", () => { + expect(pipeline.isVadGated).toBe(false); + }); + + it("has default input gain of 1.0", () => { + expect(pipeline.inputGain).toBe(1.0); + }); + + it("has zero lastVadRms by default", () => { + expect(pipeline.lastVadRms).toBe(0); + }); + + it("is not using worklet by default", () => { + expect(pipeline.vadUsingWorklet).toBe(false); + }); + }); + + describe("setRoom", () => { + it("clears the current room when set to null", () => { + pipeline.setRoom({ localParticipant: {} } as any); + pipeline.setRoom(null); + + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(false); + }); + + it("stores a room-like object for later setup", () => { + const getTrackPublication = vi.fn().mockReturnValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication, + }, + } as any; + pipeline.setRoom(mockRoom); + + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(false); + expect(getTrackPublication).toHaveBeenCalled(); + }); + }); + + describe("setupAudioPipeline", () => { + it("does nothing when no room is set", () => { + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(false); + expect(pipeline.gainValue).toBeNull(); + expect(pipeline.ctxState).toBeNull(); + }); + + it("does nothing when room has no mic track", () => { + const getTrackPublication = vi.fn().mockReturnValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication, + }, + } as any; + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(false); + expect(getTrackPublication).toHaveBeenCalled(); + }); + }); + + describe("teardownAudioPipeline", () => { + it("leaves the pipeline inactive when nothing was created", () => { + pipeline.teardownAudioPipeline(); + expect(pipeline.isActive).toBe(false); + }); + + it("resets VAD gated state", () => { + // Force vadGated to true via internal state + (pipeline as any).vadGated = true; + pipeline.teardownAudioPipeline(); + expect(pipeline.isVadGated).toBe(false); + }); + }); + + describe("applyNoiseSuppressor", () => { + it("does nothing when no room is set", async () => { + vi.mocked(createRNNoiseProcessor).mockClear(); + await expect(pipeline.applyNoiseSuppressor()).resolves.toBeUndefined(); + expect(createRNNoiseProcessor).not.toHaveBeenCalled(); + }); + + it("does nothing when no mic track exists", async () => { + const getTrackPublication = vi.fn().mockReturnValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication, + }, + } as any; + pipeline.setRoom(mockRoom); + vi.mocked(createRNNoiseProcessor).mockClear(); + await expect(pipeline.applyNoiseSuppressor()).resolves.toBeUndefined(); + expect(getTrackPublication).toHaveBeenCalledOnce(); + expect(createRNNoiseProcessor).not.toHaveBeenCalled(); + }); + }); + + describe("removeNoiseSuppressor", () => { + it("does nothing when no room is set", async () => { + await expect(pipeline.removeNoiseSuppressor()).resolves.toBeUndefined(); + expect(pipeline.isActive).toBe(false); + }); + }); + + describe("reapplyAudioProcessing", () => { + it("does nothing when no room is set", async () => { + await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); + expect(pipeline.isActive).toBe(false); + }); + + it("does nothing when room has no mic track", async () => { + const getTrackPublication = vi.fn().mockReturnValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication, + }, + } as any; + pipeline.setRoom(mockRoom); + await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); + expect(getTrackPublication).toHaveBeenCalledOnce(); + }); + + it("calls onError callback on failure", async () => { + const onError = vi.fn(); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + restartTrack: vi.fn().mockRejectedValue(new Error("device error")), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + await pipeline.reapplyAudioProcessing(onError); + expect(onError).toHaveBeenCalledWith("Failed to update audio settings"); + }); + + it("does not call onError when no callback provided", async () => { + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + restartTrack: vi.fn().mockRejectedValue(new Error("device error")), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + // Should not throw even without onError + await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); + }); + + it("does nothing when mic track is undefined", async () => { + const getTrackPublication = vi.fn().mockReturnValue({ track: undefined }); + const mockRoom = { + localParticipant: { + getTrackPublication, + }, + } as any; + pipeline.setRoom(mockRoom); + await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); + expect(getTrackPublication).toHaveBeenCalledOnce(); + }); + }); + + // --- Full AudioContext pipeline tests --- + + describe("setupAudioPipeline with AudioContext mock", () => { + let mockGainNode: any; + let mockAnalyserNode: any; + let mockDestNode: any; + let mockSourceNode: any; + let mockAudioCtx: any; + let mockRoom: any; + let mockSender: any; + + afterEach(() => { + // Ensure pipeline is torn down to clear VAD timers + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + mockAnalyserNode = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + mockDestNode = { + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted-track" }]) }, + disconnect: vi.fn(), + }; + mockSourceNode = { + connect: vi.fn(), + }; + mockSender = { + replaceTrack: vi.fn().mockResolvedValue(undefined), + }; + + mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode), + createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }; + + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "original-track" }, + sender: mockSender, + getProcessor: vi.fn().mockReturnValue(undefined), + setProcessor: vi.fn().mockResolvedValue(undefined), + stopProcessor: vi.fn().mockResolvedValue(undefined), + }, + }), + }, + }; + }); + + it("creates the full audio pipeline when room and mic track are available", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + expect(pipeline.isActive).toBe(true); + expect(mockAudioCtx.createGain).toHaveBeenCalled(); + expect(mockAudioCtx.createAnalyser).toHaveBeenCalled(); + expect(mockAudioCtx.createMediaStreamDestination).toHaveBeenCalled(); + expect(mockSourceNode.connect).toHaveBeenCalledWith(mockAnalyserNode); + expect(mockSourceNode.connect).toHaveBeenCalledWith(mockGainNode); + expect(mockGainNode.connect).toHaveBeenCalledWith(mockDestNode); + }); + + it("replaces WebRTC sender track with pipeline output", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "adjusted-track" }); + }); + + it("skips sender replacement when no adjusted track available", () => { + mockDestNode.stream.getAudioTracks.mockReturnValue([]); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + // replaceTrack is only called from teardown (not setup) since no adjusted track + // The teardown in setupAudioPipeline (line 1) calls replaceTrack for restore, + // but the setup itself should not call it with the adjusted track. + // We confirm isActive is true — the pipeline was set up successfully. + expect(pipeline.isActive).toBe(true); + }); + + it("does not replace sender if track has no sender", () => { + mockRoom.localParticipant.getTrackPublication.mockReturnValue({ + track: { + mediaStreamTrack: { id: "original-track" }, + sender: undefined, + getProcessor: vi.fn(), + }, + }); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + // Should not throw + expect(pipeline.isActive).toBe(true); + }); + + it("reads input volume from preferences during setup", () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "inputVolume") return 75; + return defaultVal; + }); + + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + expect(mockGainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.75, 0); + }); + + it("reports ctxState from active AudioContext", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + expect(pipeline.ctxState).toBe("running"); + }); + + it("reports gainValue from active GainNode", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + expect(pipeline.gainValue).toBe(1); + }); + + it("teardown disconnects and closes all nodes", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + pipeline.teardownAudioPipeline(); + + expect(pipeline.isActive).toBe(false); + expect(mockGainNode.disconnect).toHaveBeenCalled(); + expect(mockAnalyserNode.disconnect).toHaveBeenCalled(); + expect(mockDestNode.disconnect).toHaveBeenCalled(); + expect(mockAudioCtx.close).toHaveBeenCalled(); + }); + + it("teardown restores original sender track", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + mockSender.replaceTrack.mockClear(); + pipeline.teardownAudioPipeline(); + + expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "original-track" }); + }); + + it("teardown does not crash if room has no mic track", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + // Remove mic track before teardown + mockRoom.localParticipant.getTrackPublication.mockReturnValue(undefined); + expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); + }); + + it("teardown does not crash if mic track has no sender", () => { + const roomWithNoSender = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "track" }, + sender: undefined, + getProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(roomWithNoSender); + pipeline.setupAudioPipeline(); + expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); + }); + + it("setupAudioPipeline tears down existing pipeline first", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(true); + + // Second setup should tear down the first + pipeline.setupAudioPipeline(); + expect(pipeline.isActive).toBe(true); + expect(mockGainNode.disconnect).toHaveBeenCalled(); + }); + + it("updatePipelineGain applies effective gain when active", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + pipeline.setInputVolume(50); + + expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled(); + const call = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; + expect(call[0]).toBe(0.5); // inputGain = 50/100 = 0.5, not vadGated + }); + + it("updatePipelineGain sets gain to 0 when VAD is gated", () => { + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + // Force VAD gated + (pipeline as any).vadGated = true; + pipeline.updatePipelineGain(); + + const call = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; + expect(call[0]).toBe(0); + }); + + it("handles AudioContext constructor failure gracefully", () => { + vi.stubGlobal( + "AudioContext", + vi.fn(() => { + throw new Error("AudioContext not supported"); + }), + ); + pipeline.setRoom(mockRoom); + // Should not throw + expect(() => pipeline.setupAudioPipeline()).not.toThrow(); + expect(pipeline.isActive).toBe(false); + }); + }); + + // --- Noise suppressor with track --- + + describe("applyNoiseSuppressor with track", () => { + it("does nothing when track already has a processor", async () => { + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + getProcessor: vi.fn().mockReturnValue({}), // Already has processor + setProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + await pipeline.applyNoiseSuppressor(); + expect( + mockRoom.localParticipant.getTrackPublication().track.setProcessor, + ).not.toHaveBeenCalled(); + }); + + it("attaches processor when track has none", async () => { + const setProcessor = vi.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + getProcessor: vi.fn().mockReturnValue(undefined), + setProcessor, + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + await pipeline.applyNoiseSuppressor(); + expect(setProcessor).toHaveBeenCalled(); + }); + }); + + describe("removeNoiseSuppressor with track", () => { + it("does nothing when track has no processor", async () => { + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + getProcessor: vi.fn().mockReturnValue(undefined), + stopProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + await pipeline.removeNoiseSuppressor(); + expect( + mockRoom.localParticipant.getTrackPublication().track.stopProcessor, + ).not.toHaveBeenCalled(); + }); + + it("removes processor when track has one", async () => { + const stopProcessor = vi.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + getProcessor: vi.fn().mockReturnValue({}), + stopProcessor, + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + await pipeline.removeNoiseSuppressor(); + expect(stopProcessor).toHaveBeenCalled(); + }); + + it("does nothing when track is undefined", async () => { + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ track: undefined }), + }, + } as any; + pipeline.setRoom(mockRoom); + await expect(pipeline.removeNoiseSuppressor()).resolves.toBeUndefined(); + }); + }); + + describe("setupAudioPipeline AudioContext configuration", () => { + let mockAudioCtx: any; + + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("creates AudioContext with sampleRate 48000", () => { + const AudioContextSpy = vi.fn().mockReturnValue({ + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + 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().mockRejectedValue(new Error("no")) }, + }); + vi.stubGlobal("AudioContext", AudioContextSpy); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + expect(AudioContextSpy).toHaveBeenCalledWith({ sampleRate: 48000 }); + }); + + it("sets analyser fftSize to 2048", () => { + const mockAnalyser = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue(mockAnalyser), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + expect(mockAnalyser.fftSize).toBe(2048); + }); + + it("sets analyser smoothingTimeConstant to 0.3", () => { + const mockAnalyser = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue(mockAnalyser), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + expect(mockAnalyser.smoothingTimeConstant).toBe(0.3); + }); + + it("calls ctx.resume() during setup", () => { + mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + expect(mockAudioCtx.resume).toHaveBeenCalled(); + }); + }); + + describe("teardownAudioPipeline increments generation", () => { + it("increments _pipelineGeneration on each teardown", () => { + const gen0 = (pipeline as any)._pipelineGeneration; + pipeline.teardownAudioPipeline(); + expect((pipeline as any)._pipelineGeneration).toBe(gen0 + 1); + pipeline.teardownAudioPipeline(); + expect((pipeline as any)._pipelineGeneration).toBe(gen0 + 2); + }); + }); + + describe("teardownAudioPipeline handles replaceTrack failure gracefully", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not throw when sender.replaceTrack rejects during teardown", () => { + 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({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "t" }, + sender: { replaceTrack: vi.fn().mockRejectedValue(new Error("fail")) }, + getProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); + expect(pipeline.isActive).toBe(false); + }); + }); + + describe("setupAudioPipeline sender.replaceTrack failure during setup", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("catches replaceTrack rejection during setup without crashing", () => { + 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({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted" }]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "t" }, + sender: { replaceTrack: vi.fn().mockRejectedValue(new Error("replace fail")) }, + getProcessor: vi.fn(), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + expect(() => pipeline.setupAudioPipeline()).not.toThrow(); + expect(pipeline.isActive).toBe(true); + }); + }); + + describe("reapplyAudioProcessing success path", () => { + it("restarts track, rebuilds pipeline, and applies enhanced NS", async () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "enhancedNoiseSuppression") return true; + if (key === "echoCancellation") return true; + if (key === "noiseSuppression") return true; + if (key === "autoGainControl") return true; + return defaultVal; + }); + + const restartTrack = vi.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + restartTrack, + mediaStreamTrack: { id: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn().mockReturnValue(undefined), + setProcessor: vi.fn().mockResolvedValue(undefined), + }, + }), + }, + } as any; + + // Stub AudioContext for setupAudioPipeline called internally + vi.stubGlobal( + "AudioContext", + vi.fn().mockReturnValue({ + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }), + ); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + pipeline.setRoom(mockRoom); + await pipeline.reapplyAudioProcessing(); + + expect(restartTrack).toHaveBeenCalledWith({ + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); + }); + + it("removes noise suppressor when enhanced NS is disabled", async () => { + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "enhancedNoiseSuppression") return false; + if (key === "echoCancellation") return true; + if (key === "noiseSuppression") return true; + if (key === "autoGainControl") return true; + return defaultVal; + }); + + const stopProcessor = vi.fn().mockResolvedValue(undefined); + const restartTrack = vi.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + restartTrack, + mediaStreamTrack: { id: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn().mockReturnValue({}), // has a processor + setProcessor: vi.fn().mockResolvedValue(undefined), + stopProcessor, + }, + }), + }, + } as any; + + vi.stubGlobal( + "AudioContext", + vi.fn().mockReturnValue({ + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }), + ); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + pipeline.setRoom(mockRoom); + await pipeline.reapplyAudioProcessing(); + + expect(restartTrack).toHaveBeenCalled(); + expect(stopProcessor).toHaveBeenCalled(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/audio-pipeline-gain.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-gain.test.ts new file mode 100644 index 00000000..b4837057 --- /dev/null +++ b/Client/tauri-client/tests/unit/audio-pipeline-gain.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), + mockSavePref: vi.fn(), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: (key: string, val: unknown) => mockSavePref(key, val), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/noise-suppression", () => ({ + createRNNoiseProcessor: vi.fn(), +})); + +vi.mock("livekit-client", () => ({ + Track: { + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, + }, +})); + +import { AudioPipeline } from "../../src/lib/audioPipeline"; + +describe("AudioPipeline", () => { + let pipeline: AudioPipeline; + + beforeEach(() => { + vi.clearAllMocks(); + pipeline = new AudioPipeline(); + }); + + describe("setInputVolume", () => { + it("saves clamped volume to preferences", () => { + pipeline.setInputVolume(75); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 75); + }); + + it("clamps to 0-200 range", () => { + pipeline.setInputVolume(-10); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); + expect(pipeline.inputGain).toBe(0); + + pipeline.setInputVolume(250); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); + expect(pipeline.inputGain).toBe(2.0); + }); + + it("updates inputGain property", () => { + pipeline.setInputVolume(150); + expect(pipeline.inputGain).toBe(1.5); + }); + }); + + describe("setVoiceSensitivity", () => { + it("saves clamped sensitivity to preferences", () => { + pipeline.setVoiceSensitivity(50); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50); + }); + + it("clamps to 0-100 range", () => { + pipeline.setVoiceSensitivity(-5); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); + + pipeline.setVoiceSensitivity(150); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); + }); + + it("persists sensitivity value even when no pipeline is active", () => { + pipeline.setVoiceSensitivity(50); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50); + // Pipeline is not active so VAD gating remains off + expect(pipeline.isVadGated).toBe(false); + expect(pipeline.isActive).toBe(false); + }); + }); + + describe("updatePipelineGain", () => { + it("leaves gainValue null when no pipeline exists", () => { + pipeline.updatePipelineGain(); + expect(pipeline.gainValue).toBeNull(); + }); + }); + + describe("setVoiceSensitivity edge cases", () => { + it("sensitivity 100 ungates if previously gated", () => { + (pipeline as any).vadGated = true; + pipeline.setVoiceSensitivity(100); + expect(pipeline.isVadGated).toBe(false); + }); + + it("sensitivity below 100 does not change gated state without active pipeline", () => { + pipeline.setVoiceSensitivity(50); + // No crash, no active pipeline to start VAD on + expect(pipeline.isVadGated).toBe(false); + }); + }); + + describe("setInputVolume boundary and arithmetic precision", () => { + it("volume 0 produces inputGain exactly 0", () => { + pipeline.setInputVolume(0); + expect(pipeline.inputGain).toBe(0); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); + }); + + it("volume 200 produces inputGain exactly 2.0", () => { + pipeline.setInputVolume(200); + expect(pipeline.inputGain).toBe(2.0); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); + }); + + it("volume 100 produces inputGain exactly 1.0", () => { + pipeline.setInputVolume(100); + expect(pipeline.inputGain).toBe(1.0); + }); + + it("volume 1 produces inputGain 0.01", () => { + pipeline.setInputVolume(1); + expect(pipeline.inputGain).toBeCloseTo(0.01, 5); + }); + + it("negative volume clamps to 0 (not negative)", () => { + pipeline.setInputVolume(-100); + expect(pipeline.inputGain).toBe(0); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); + }); + + it("volume above 200 clamps to 200 (not raw value)", () => { + pipeline.setInputVolume(500); + expect(pipeline.inputGain).toBe(2.0); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); + }); + + it("volume exactly at lower boundary (0) is saved as 0, not clamped further", () => { + pipeline.setInputVolume(0); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); + }); + + it("volume exactly at upper boundary (200) is saved as 200, not clamped further", () => { + pipeline.setInputVolume(200); + expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); + }); + }); + + describe("setVoiceSensitivity boundary and arithmetic precision", () => { + it("sensitivity 0 clamps to 0 and saves", () => { + pipeline.setVoiceSensitivity(0); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); + }); + + it("sensitivity exactly 100 saves 100", () => { + pipeline.setVoiceSensitivity(100); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); + }); + + it("sensitivity exactly 99 saves 99 (below 100 threshold)", () => { + pipeline.setVoiceSensitivity(99); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 99); + }); + + it("sensitivity above 100 clamps to 100", () => { + pipeline.setVoiceSensitivity(200); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); + }); + + it("sensitivity below 0 clamps to 0", () => { + pipeline.setVoiceSensitivity(-50); + expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); + }); + + it("sensitivity 100 does NOT ungate when already ungated", () => { + // vadGated is false by default; sensitivity 100 should not crash or change state + expect(pipeline.isVadGated).toBe(false); + pipeline.setVoiceSensitivity(100); + expect(pipeline.isVadGated).toBe(false); + }); + + it("sensitivity < 100 calls stopVadPolling which ungates, then restarts polling", () => { + (pipeline as any).vadGated = true; + // setVoiceSensitivity calls stopVadPolling() first, which ungates + pipeline.setVoiceSensitivity(99); + // stopVadPolling always ungates if gated + expect(pipeline.isVadGated).toBe(false); + }); + + it("sensitivity >= 100 ungates immediately without starting VAD", () => { + (pipeline as any).vadGated = true; + pipeline.setVoiceSensitivity(100); + expect(pipeline.isVadGated).toBe(false); + }); + }); + + describe("updatePipelineGain effective gain logic", () => { + let mockGainNode: any; + let mockAudioCtx: any; + + beforeEach(() => { + mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + mockAudioCtx = { + currentTime: 0.5, + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) }, + disconnect: vi.fn(), + }), + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + }); + + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("uses setTargetAtTime with smoothing constant 0.015", () => { + 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(); + mockGainNode.gain.setTargetAtTime.mockClear(); + + pipeline.setInputVolume(80); + const lastCall = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; + expect(lastCall[2]).toBe(0.015); // smoothing time constant + }); + + it("uses ctx.currentTime as the start time for setTargetAtTime", () => { + 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(); + mockGainNode.gain.setTargetAtTime.mockClear(); + + pipeline.setInputVolume(60); + const lastCall = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; + expect(lastCall[1]).toBe(0.5); // ctx.currentTime + }); + + it("gain is currentInputGain when not vadGated", () => { + 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(); + pipeline.setInputVolume(130); + mockGainNode.gain.setTargetAtTime.mockClear(); + + pipeline.updatePipelineGain(); + const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0]; + expect(lastCall[0]).toBe(1.3); // 130 / 100 + }); + + it("gain is exactly 0 when vadGated, regardless of inputGain", () => { + 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(); + pipeline.setInputVolume(200); + (pipeline as any).vadGated = true; + mockGainNode.gain.setTargetAtTime.mockClear(); + + pipeline.updatePipelineGain(); + const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0]; + expect(lastCall[0]).toBe(0); + }); + + it("does nothing when audioPipelineGain is null but ctx is not", () => { + // Set pipeline state to have ctx but no gain — simulates partial teardown + (pipeline as any).audioPipelineCtx = mockAudioCtx; + (pipeline as any).audioPipelineGain = null; + mockGainNode.gain.setTargetAtTime.mockClear(); + pipeline.updatePipelineGain(); + expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); + }); + + it("does nothing when audioPipelineCtx is null but gain is not", () => { + (pipeline as any).audioPipelineGain = mockGainNode; + (pipeline as any).audioPipelineCtx = null; + mockGainNode.gain.setTargetAtTime.mockClear(); + pipeline.updatePipelineGain(); + expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); + }); + }); + + describe("setInputVolume calls updatePipelineGain", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("calls updatePipelineGain which is no-op without active pipeline", () => { + // No active pipeline — updatePipelineGain should not throw + pipeline.setInputVolume(50); + expect(pipeline.inputGain).toBe(0.5); + expect(pipeline.gainValue).toBeNull(); // no pipeline + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/audio-pipeline-vad-fallback.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-vad-fallback.test.ts new file mode 100644 index 00000000..b152a2cc --- /dev/null +++ b/Client/tauri-client/tests/unit/audio-pipeline-vad-fallback.test.ts @@ -0,0 +1,599 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), + mockSavePref: vi.fn(), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: (key: string, val: unknown) => mockSavePref(key, val), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/noise-suppression", () => ({ + createRNNoiseProcessor: vi.fn(), +})); + +vi.mock("livekit-client", () => ({ + Track: { + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, + }, +})); + +import { AudioPipeline } from "../../src/lib/audioPipeline"; + +describe("AudioPipeline", () => { + let pipeline: AudioPipeline; + + beforeEach(() => { + vi.clearAllMocks(); + pipeline = new AudioPipeline(); + }); + + describe("VAD fallback polling", () => { + afterEach(() => { + // Stop VAD first to clear the setTimeout chain before teardown + pipeline.stopVadPolling(); + pipeline.teardownAudioPipeline(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("gates audio after sustained silence", async () => { + vi.useFakeTimers(); + const dataArray = new Float32Array(2048); + // Fill with silence + dataArray.fill(0); + + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + arr.set(dataArray); + }), + }; + 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: "track" }]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }; + + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), + }, + }), + }, + } as any; + + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + // Wait for worklet to fail and fallback to start + await vi.advanceTimersByTimeAsync(100); + + // Run enough frames to pass startup grace (30 frames * 16ms = 480ms) + // and then enough silent frames to trigger gate (12 frames * 16ms = 192ms) + await vi.advanceTimersByTimeAsync(1200); + + expect(pipeline.isVadGated).toBe(true); + }); + + it("ungates audio after speech is detected", async () => { + vi.useFakeTimers(); + let isSilent = true; + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + if (isSilent) { + arr.fill(0); + } else { + // Fill with loud signal + for (let i = 0; i < arr.length; i++) arr[i] = 0.5; + } + }), + }; + 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: "track" }]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }; + + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), + }, + }), + }, + } as any; + + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); + + // Gate first with silence + await vi.advanceTimersByTimeAsync(1200); + expect(pipeline.isVadGated).toBe(true); + + // Now simulate speech + isSilent = false; + await vi.advanceTimersByTimeAsync(200); + expect(pipeline.isVadGated).toBe(false); + }); + }); + + // --- Mutation-killing tests: boundary conditions, arithmetic, boolean logic --- + + describe("VAD fallback frame counters and RMS reporting", () => { + afterEach(() => { + pipeline.stopVadPolling(); + pipeline.teardownAudioPipeline(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function setupFallbackPipeline(): { mockAnalyser: any; mockGainNode: any } { + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + // Moderate signal — above threshold so we can test non-gating + for (let i = 0; i < arr.length; i++) arr[i] = 0.3; + }), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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); + return { mockAnalyser, mockGainNode }; + } + + it("updates _lastVadRms every 3 frames (frameCounter >= 3 resets)", async () => { + vi.useFakeTimers(); + setupFallbackPipeline(); + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); // worklet fails + // RMS for constant 0.3 signal: sqrt(0.09) = 0.3 + // After startup grace (30 frames), frameCounter increments 1,2,3 -> reset + update + await vi.advanceTimersByTimeAsync(1000); + + // lastVadRms should have been updated to ~0.3 (the RMS of constant 0.3 signal) + expect(pipeline.lastVadRms).toBeGreaterThan(0); + expect(pipeline.lastVadRms).toBeCloseTo(0.3, 1); + }); + + it("does not gate when rms is above threshold (speech frames accumulate)", async () => { + vi.useFakeTimers(); + setupFallbackPipeline(); // signal at 0.3, threshold = 0.05 + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(1200); + // rms 0.3 > threshold 0.05, so silentFrames never accumulate, no gating + expect(pipeline.isVadGated).toBe(false); + }); + + it("gate requires exactly GATE_ON_FRAMES (12) consecutive silent frames", async () => { + vi.useFakeTimers(); + let frameCount = 0; + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + frameCount++; + // After startup grace (30 frames), be silent for exactly 11 frames, then loud + if (frameCount > 30 && frameCount <= 41) { + arr.fill(0); // silent + } else if (frameCount === 42) { + for (let i = 0; i < arr.length; i++) arr[i] = 0.5; // loud — resets counter + } else if (frameCount > 42) { + arr.fill(0); // silent again — needs 12 more to gate + } else { + arr.fill(0); // startup grace + } + }), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + await vi.advanceTimersByTimeAsync(100); // worklet fails + // Run through startup (30 frames) + 11 silent + 1 loud = 42 frames * 16ms = 672ms + await vi.advanceTimersByTimeAsync(700); + // After 11 silent frames then 1 loud: should NOT be gated yet (needs 12 consecutive) + // The loud frame resets silentFrames to 0 + + // Now run 12 more silent frames to trigger gating + await vi.advanceTimersByTimeAsync(250); // 12+ frames * 16ms + expect(pipeline.isVadGated).toBe(true); + }); + + it("ungate requires GATE_OFF_FRAMES (2) consecutive speech frames after gating", async () => { + vi.useFakeTimers(); + let isSilent = true; + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + if (isSilent) { + arr.fill(0); + } else { + for (let i = 0; i < arr.length; i++) arr[i] = 0.5; + } + }), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + // Wait for worklet to fail and fallback to start + await vi.advanceTimersByTimeAsync(100); + // Gate with silence: startup grace (30*16=480ms) + gate frames (12*16=192ms) + await vi.advanceTimersByTimeAsync(1200); + expect(pipeline.isVadGated).toBe(true); + + // Switch to speech — need 2 consecutive speech frames (GATE_OFF_FRAMES) to ungate + isSilent = false; + await vi.advanceTimersByTimeAsync(200); // 2+ frames * 16ms + expect(pipeline.isVadGated).toBe(false); + }); + + it("startup grace period skips first 30 frames without gating", async () => { + vi.useFakeTimers(); + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { + arr.fill(0); // always silent + }), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + await vi.advanceTimersByTimeAsync(100); // worklet fails + // Only run startup grace period: 30 frames * 16ms = 480ms + // Gate needs 12 more frames after grace + await vi.advanceTimersByTimeAsync(480); + // During grace period, no gating should occur despite silence + // But after grace + ~12 frames (192ms), gating occurs + // So at ~580ms from fallback start, should not yet be gated + // (480ms grace + only a few post-grace frames) + // Let's check at exactly the grace boundary + expect(pipeline.isVadGated).toBe(false); + + // Now advance past grace + 12 gate frames + await vi.advanceTimersByTimeAsync(300); + expect(pipeline.isVadGated).toBe(true); + }); + }); + + describe("VAD fallback stops when analyser is torn down mid-poll", () => { + afterEach(() => { + pipeline.stopVadPolling(); + pipeline.teardownAudioPipeline(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("poll stops iterating when analyser becomes null", async () => { + vi.useFakeTimers(); + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + await vi.advanceTimersByTimeAsync(100); + + // Null out the analyser mid-poll + (pipeline as any).audioPipelineAnalyser = null; + const callsBefore = mockAnalyser.getFloatTimeDomainData.mock.calls.length; + + await vi.advanceTimersByTimeAsync(200); + // No new calls should happen since analyser is null + expect(mockAnalyser.getFloatTimeDomainData.mock.calls.length).toBe(callsBefore); + }); + }); +}); 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 new file mode 100644 index 00000000..8b7412e9 --- /dev/null +++ b/Client/tauri-client/tests/unit/audio-pipeline-vad-worklet.test.ts @@ -0,0 +1,698 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), + mockSavePref: vi.fn(), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: (key: string, val: unknown) => mockSavePref(key, val), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@lib/noise-suppression", () => ({ + createRNNoiseProcessor: vi.fn(), +})); + +vi.mock("livekit-client", () => ({ + Track: { + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, + }, +})); + +import { AudioPipeline } from "../../src/lib/audioPipeline"; + +describe("AudioPipeline", () => { + let pipeline: AudioPipeline; + + beforeEach(() => { + vi.clearAllMocks(); + pipeline = new AudioPipeline(); + }); + + describe("startVadPolling", () => { + it("does not activate VAD without an analyser", () => { + pipeline.startVadPolling(); + expect(pipeline.vadUsingWorklet).toBe(false); + expect(pipeline.lastVadRms).toBe(0); + }); + }); + + describe("stopVadPolling", () => { + it("is idempotent when no VAD is running", () => { + pipeline.stopVadPolling(); + pipeline.stopVadPolling(); + expect(pipeline.lastVadRms).toBe(0); + }); + + it("resets lastVadRms to 0", () => { + (pipeline as any)._lastVadRms = 0.5; + pipeline.stopVadPolling(); + expect(pipeline.lastVadRms).toBe(0); + }); + + it("ungates if was gated", () => { + (pipeline as any).vadGated = true; + pipeline.stopVadPolling(); + expect(pipeline.isVadGated).toBe(false); + }); + }); + + describe("VAD worklet path", () => { + let mockGainNode: any; + let mockAnalyserNode: any; + let mockDestNode: any; + let mockSourceNode: any; + let mockAudioCtx: any; + let mockRoom: any; + + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + function setupPipelineWithWorklet(workletBehavior: "success" | "fail"): void { + mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + mockAnalyserNode = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + mockDestNode = { + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) }, + disconnect: vi.fn(), + }; + mockSourceNode = { connect: vi.fn() }; + mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode), + createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode), + createGain: vi.fn().mockReturnValue(mockGainNode), + createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { + addModule: + workletBehavior === "success" + ? vi.fn().mockResolvedValue(undefined) + : vi.fn().mockRejectedValue(new Error("no worklet")), + }, + }; + + // Mock AudioWorkletNode + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => ({ + port: { + postMessage: vi.fn(), + onmessage: null as ((event: MessageEvent) => void) | null, + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + ); + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "track" }, + sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), + }, + }), + }, + }; + + // Set sensitivity < 100 so VAD polling starts + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "voiceSensitivity") return 50; + if (key === "inputVolume") return 100; + return defaultVal; + }); + } + + it("starts VAD worklet when AudioWorklet addModule succeeds", async () => { + setupPipelineWithWorklet("success"); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + // Wait for the async addModule to resolve + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + }); + + it("falls back to setTimeout VAD when AudioWorklet addModule fails", async () => { + setupPipelineWithWorklet("fail"); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + // After worklet failure, falls back to setTimeout + expect(pipeline.vadUsingWorklet).toBe(false); + }); + }); + + it("worklet gate message toggles VAD gate", async () => { + setupPipelineWithWorklet("success"); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + // Get the AudioWorkletNode mock and simulate a gate message + const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; + const workletInstance = WorkletNodeConstructor.mock.results[0].value; + + // Simulate gate message + workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); + expect(pipeline.isVadGated).toBe(true); + + workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any); + expect(pipeline.isVadGated).toBe(false); + }); + + it("worklet rms message updates lastVadRms", async () => { + setupPipelineWithWorklet("success"); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; + const workletInstance = WorkletNodeConstructor.mock.results[0].value; + + workletInstance.port.onmessage({ data: { type: "rms", value: 0.42 } } as any); + expect(pipeline.lastVadRms).toBe(0.42); + }); + + it("stopVadPolling disconnects worklet node", async () => { + setupPipelineWithWorklet("success"); + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; + const workletInstance = WorkletNodeConstructor.mock.results[0].value; + + pipeline.stopVadPolling(); + + expect(workletInstance.port.postMessage).toHaveBeenCalledWith({ type: "stop" }); + expect(workletInstance.disconnect).toHaveBeenCalled(); + expect(pipeline.vadUsingWorklet).toBe(false); + }); + + it("falls back to setTimeout when AudioWorkletNode constructor throws", async () => { + setupPipelineWithWorklet("success"); + // Override AudioWorkletNode to throw + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => { + throw new Error("AudioWorkletNode not supported"); + }), + ); + + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + await vi.waitFor(() => { + // Should have fallen back to setTimeout + expect(pipeline.vadUsingWorklet).toBe(false); + }); + }); + }); + + describe("startVadPolling threshold calculation and sensitivity guard", () => { + let mockAnalyser: any; + let mockGainNode: any; + let mockAudioCtx: any; + + afterEach(() => { + pipeline.stopVadPolling(); + pipeline.teardownAudioPipeline(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function setupPipelineForVad(sensitivity: number): void { + mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), + }; + mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "voiceSensitivity") return sensitivity; + 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); + } + + it("sensitivity 100 prevents VAD from starting (no polling)", async () => { + vi.useFakeTimers(); + setupPipelineForVad(100); + pipeline.setupAudioPipeline(); + + // Wait for async paths to settle + await vi.advanceTimersByTimeAsync(200); + + // VAD should not be running - no gate should happen even after lots of silence + await vi.advanceTimersByTimeAsync(2000); + expect(pipeline.isVadGated).toBe(false); + }); + + it("sensitivity 99 allows VAD to start and eventually gate silence", async () => { + vi.useFakeTimers(); + setupPipelineForVad(99); + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); // worklet fails, fallback starts + await vi.advanceTimersByTimeAsync(1200); // startup grace + gate frames + expect(pipeline.isVadGated).toBe(true); + }); + + it("sensitivity 0 produces high threshold that gates easily", async () => { + vi.useFakeTimers(); + setupPipelineForVad(0); + // threshold = ((100 - 0) / 100) * 0.1 = 0.1 + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(1200); + expect(pipeline.isVadGated).toBe(true); + }); + + it("sensitivity 50 produces threshold 0.05", async () => { + vi.useFakeTimers(); + setupPipelineForVad(50); + // threshold = ((100 - 50) / 100) * 0.1 = 0.05 + // silence (rms=0) < 0.05, so should gate + pipeline.setupAudioPipeline(); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(1200); + expect(pipeline.isVadGated).toBe(true); + }); + }); + + describe("pipeline generation prevents stale async results", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("discards worklet addModule result if pipeline torn down during load", async () => { + let resolveAddModule: () => void; + const addModulePromise = new Promise((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(); + + // Teardown increments generation, making the pending addModule stale + pipeline.teardownAudioPipeline(); + + // Now resolve addModule — should be discarded because generation changed + resolveAddModule!(); + await addModulePromise; + + // Yield to microtasks + await new Promise((r) => setTimeout(r, 0)); + + // Worklet should NOT have been started (generation mismatch) + expect(pipeline.vadUsingWorklet).toBe(false); + }); + }); + + describe("worklet gate message deduplication", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("does not call updatePipelineGain when gate state unchanged", async () => { + const mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + const mockAnalyser = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: 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().mockResolvedValue(undefined) }, + }; + 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(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; + const workletInstance = WorkletNodeConstructor.mock.results[0].value; + mockGainNode.gain.setTargetAtTime.mockClear(); + + // Send gate=false when already ungated — should NOT trigger updatePipelineGain + workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any); + expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); + + // Send gate=true — should trigger + workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); + expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled(); + mockGainNode.gain.setTargetAtTime.mockClear(); + + // Send gate=true again — should NOT trigger (already gated) + workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); + expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); + }); + }); + + describe("worklet sends config with threshold", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + it("posts config message with correct threshold to worklet port", async () => { + const mockGainNode = { + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + const mockAnalyser = { + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }; + const postMessageSpy = 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().mockResolvedValue(undefined) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => ({ + port: { postMessage: postMessageSpy, onmessage: null }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + ); + + mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { + if (key === "voiceSensitivity") return 50; // threshold = ((100-50)/100)*0.1 = 0.05 + 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(); + + await vi.waitFor(() => { + expect(pipeline.vadUsingWorklet).toBe(true); + }); + + expect(postMessageSpy).toHaveBeenCalledWith({ type: "config", threshold: 0.05 }); + }); + }); + + describe("stopVadPolling clears vadTimer", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("clears the setTimeout-based vadTimer on stop", async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + + const mockAnalyser = { + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), + }; + 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().mockRejectedValue(new Error("no")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); + + 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(); + + await vi.advanceTimersByTimeAsync(100); // fallback starts + clearTimeoutSpy.mockClear(); + + pipeline.stopVadPolling(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/audio-pipeline.test.ts b/Client/tauri-client/tests/unit/audio-pipeline.test.ts deleted file mode 100644 index 8983670a..00000000 --- a/Client/tauri-client/tests/unit/audio-pipeline.test.ts +++ /dev/null @@ -1,2547 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ - mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), - mockSavePref: vi.fn(), -})); - -vi.mock("@components/settings/helpers", () => ({ - loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), - savePref: (key: string, val: unknown) => mockSavePref(key, val), -})); - -vi.mock("@lib/logger", () => ({ - createLogger: () => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -vi.mock("@lib/noise-suppression", () => ({ - createRNNoiseProcessor: vi.fn(), -})); - -vi.mock("livekit-client", () => ({ - Track: { - Source: { - Microphone: "microphone", - Camera: "camera", - ScreenShare: "screenShare", - ScreenShareAudio: "screenShareAudio", - }, - }, -})); - -import { AudioPipeline } from "../../src/lib/audioPipeline"; -import { createRNNoiseProcessor } from "../../src/lib/noise-suppression"; - -describe("AudioPipeline", () => { - let pipeline: AudioPipeline; - - beforeEach(() => { - vi.clearAllMocks(); - pipeline = new AudioPipeline(); - }); - - describe("initial state", () => { - it("is not active by default", () => { - expect(pipeline.isActive).toBe(false); - }); - - it("has null gainValue when inactive", () => { - expect(pipeline.gainValue).toBeNull(); - }); - - it("has null ctxState when inactive", () => { - expect(pipeline.ctxState).toBeNull(); - }); - - it("is not VAD gated by default", () => { - expect(pipeline.isVadGated).toBe(false); - }); - - it("has default input gain of 1.0", () => { - expect(pipeline.inputGain).toBe(1.0); - }); - - it("has zero lastVadRms by default", () => { - expect(pipeline.lastVadRms).toBe(0); - }); - - it("is not using worklet by default", () => { - expect(pipeline.vadUsingWorklet).toBe(false); - }); - }); - - describe("setRoom", () => { - it("clears the current room when set to null", () => { - pipeline.setRoom({ localParticipant: {} } as any); - pipeline.setRoom(null); - - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(false); - }); - - it("stores a room-like object for later setup", () => { - const getTrackPublication = vi.fn().mockReturnValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication, - }, - } as any; - pipeline.setRoom(mockRoom); - - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(false); - expect(getTrackPublication).toHaveBeenCalled(); - }); - }); - - describe("setInputVolume", () => { - it("saves clamped volume to preferences", () => { - pipeline.setInputVolume(75); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 75); - }); - - it("clamps to 0-200 range", () => { - pipeline.setInputVolume(-10); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); - expect(pipeline.inputGain).toBe(0); - - pipeline.setInputVolume(250); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); - expect(pipeline.inputGain).toBe(2.0); - }); - - it("updates inputGain property", () => { - pipeline.setInputVolume(150); - expect(pipeline.inputGain).toBe(1.5); - }); - }); - - describe("setVoiceSensitivity", () => { - it("saves clamped sensitivity to preferences", () => { - pipeline.setVoiceSensitivity(50); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50); - }); - - it("clamps to 0-100 range", () => { - pipeline.setVoiceSensitivity(-5); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); - - pipeline.setVoiceSensitivity(150); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); - }); - - it("persists sensitivity value even when no pipeline is active", () => { - pipeline.setVoiceSensitivity(50); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50); - // Pipeline is not active so VAD gating remains off - expect(pipeline.isVadGated).toBe(false); - expect(pipeline.isActive).toBe(false); - }); - }); - - describe("setupAudioPipeline", () => { - it("does nothing when no room is set", () => { - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(false); - expect(pipeline.gainValue).toBeNull(); - expect(pipeline.ctxState).toBeNull(); - }); - - it("does nothing when room has no mic track", () => { - const getTrackPublication = vi.fn().mockReturnValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication, - }, - } as any; - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(false); - expect(getTrackPublication).toHaveBeenCalled(); - }); - }); - - describe("teardownAudioPipeline", () => { - it("leaves the pipeline inactive when nothing was created", () => { - pipeline.teardownAudioPipeline(); - expect(pipeline.isActive).toBe(false); - }); - - it("resets VAD gated state", () => { - // Force vadGated to true via internal state - (pipeline as any).vadGated = true; - pipeline.teardownAudioPipeline(); - expect(pipeline.isVadGated).toBe(false); - }); - }); - - describe("updatePipelineGain", () => { - it("leaves gainValue null when no pipeline exists", () => { - pipeline.updatePipelineGain(); - expect(pipeline.gainValue).toBeNull(); - }); - }); - - describe("startVadPolling", () => { - it("does not activate VAD without an analyser", () => { - pipeline.startVadPolling(); - expect(pipeline.vadUsingWorklet).toBe(false); - expect(pipeline.lastVadRms).toBe(0); - }); - }); - - describe("stopVadPolling", () => { - it("is idempotent when no VAD is running", () => { - pipeline.stopVadPolling(); - pipeline.stopVadPolling(); - expect(pipeline.lastVadRms).toBe(0); - }); - - it("resets lastVadRms to 0", () => { - (pipeline as any)._lastVadRms = 0.5; - pipeline.stopVadPolling(); - expect(pipeline.lastVadRms).toBe(0); - }); - - it("ungates if was gated", () => { - (pipeline as any).vadGated = true; - pipeline.stopVadPolling(); - expect(pipeline.isVadGated).toBe(false); - }); - }); - - describe("applyNoiseSuppressor", () => { - it("does nothing when no room is set", async () => { - vi.mocked(createRNNoiseProcessor).mockClear(); - await expect(pipeline.applyNoiseSuppressor()).resolves.toBeUndefined(); - expect(createRNNoiseProcessor).not.toHaveBeenCalled(); - }); - - it("does nothing when no mic track exists", async () => { - const getTrackPublication = vi.fn().mockReturnValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication, - }, - } as any; - pipeline.setRoom(mockRoom); - vi.mocked(createRNNoiseProcessor).mockClear(); - await expect(pipeline.applyNoiseSuppressor()).resolves.toBeUndefined(); - expect(getTrackPublication).toHaveBeenCalledOnce(); - expect(createRNNoiseProcessor).not.toHaveBeenCalled(); - }); - }); - - describe("removeNoiseSuppressor", () => { - it("does nothing when no room is set", async () => { - await expect(pipeline.removeNoiseSuppressor()).resolves.toBeUndefined(); - expect(pipeline.isActive).toBe(false); - }); - }); - - describe("reapplyAudioProcessing", () => { - it("does nothing when no room is set", async () => { - await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); - expect(pipeline.isActive).toBe(false); - }); - - it("does nothing when room has no mic track", async () => { - const getTrackPublication = vi.fn().mockReturnValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication, - }, - } as any; - pipeline.setRoom(mockRoom); - await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); - expect(getTrackPublication).toHaveBeenCalledOnce(); - }); - - it("calls onError callback on failure", async () => { - const onError = vi.fn(); - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - restartTrack: vi.fn().mockRejectedValue(new Error("device error")), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - await pipeline.reapplyAudioProcessing(onError); - expect(onError).toHaveBeenCalledWith("Failed to update audio settings"); - }); - - it("does not call onError when no callback provided", async () => { - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - restartTrack: vi.fn().mockRejectedValue(new Error("device error")), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - // Should not throw even without onError - await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); - }); - - it("does nothing when mic track is undefined", async () => { - const getTrackPublication = vi.fn().mockReturnValue({ track: undefined }); - const mockRoom = { - localParticipant: { - getTrackPublication, - }, - } as any; - pipeline.setRoom(mockRoom); - await expect(pipeline.reapplyAudioProcessing()).resolves.toBeUndefined(); - expect(getTrackPublication).toHaveBeenCalledOnce(); - }); - }); - - // --- Full AudioContext pipeline tests --- - - describe("setupAudioPipeline with AudioContext mock", () => { - let mockGainNode: any; - let mockAnalyserNode: any; - let mockDestNode: any; - let mockSourceNode: any; - let mockAudioCtx: any; - let mockRoom: any; - let mockSender: any; - - afterEach(() => { - // Ensure pipeline is torn down to clear VAD timers - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - beforeEach(() => { - mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - mockAnalyserNode = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }; - mockDestNode = { - stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted-track" }]) }, - disconnect: vi.fn(), - }; - mockSourceNode = { - connect: vi.fn(), - }; - mockSender = { - replaceTrack: vi.fn().mockResolvedValue(undefined), - }; - - mockAudioCtx = { - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode), - createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode), - createGain: vi.fn().mockReturnValue(mockGainNode), - createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - }; - - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - mediaStreamTrack: { id: "original-track" }, - sender: mockSender, - getProcessor: vi.fn().mockReturnValue(undefined), - setProcessor: vi.fn().mockResolvedValue(undefined), - stopProcessor: vi.fn().mockResolvedValue(undefined), - }, - }), - }, - }; - }); - - it("creates the full audio pipeline when room and mic track are available", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - expect(pipeline.isActive).toBe(true); - expect(mockAudioCtx.createGain).toHaveBeenCalled(); - expect(mockAudioCtx.createAnalyser).toHaveBeenCalled(); - expect(mockAudioCtx.createMediaStreamDestination).toHaveBeenCalled(); - expect(mockSourceNode.connect).toHaveBeenCalledWith(mockAnalyserNode); - expect(mockSourceNode.connect).toHaveBeenCalledWith(mockGainNode); - expect(mockGainNode.connect).toHaveBeenCalledWith(mockDestNode); - }); - - it("replaces WebRTC sender track with pipeline output", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "adjusted-track" }); - }); - - it("skips sender replacement when no adjusted track available", () => { - mockDestNode.stream.getAudioTracks.mockReturnValue([]); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - // replaceTrack is only called from teardown (not setup) since no adjusted track - // The teardown in setupAudioPipeline (line 1) calls replaceTrack for restore, - // but the setup itself should not call it with the adjusted track. - // We confirm isActive is true — the pipeline was set up successfully. - expect(pipeline.isActive).toBe(true); - }); - - it("does not replace sender if track has no sender", () => { - mockRoom.localParticipant.getTrackPublication.mockReturnValue({ - track: { - mediaStreamTrack: { id: "original-track" }, - sender: undefined, - getProcessor: vi.fn(), - }, - }); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - // Should not throw - expect(pipeline.isActive).toBe(true); - }); - - it("reads input volume from preferences during setup", () => { - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "inputVolume") return 75; - return defaultVal; - }); - - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - expect(mockGainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.75, 0); - }); - - it("reports ctxState from active AudioContext", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - expect(pipeline.ctxState).toBe("running"); - }); - - it("reports gainValue from active GainNode", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - expect(pipeline.gainValue).toBe(1); - }); - - it("teardown disconnects and closes all nodes", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - pipeline.teardownAudioPipeline(); - - expect(pipeline.isActive).toBe(false); - expect(mockGainNode.disconnect).toHaveBeenCalled(); - expect(mockAnalyserNode.disconnect).toHaveBeenCalled(); - expect(mockDestNode.disconnect).toHaveBeenCalled(); - expect(mockAudioCtx.close).toHaveBeenCalled(); - }); - - it("teardown restores original sender track", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - mockSender.replaceTrack.mockClear(); - pipeline.teardownAudioPipeline(); - - expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "original-track" }); - }); - - it("teardown does not crash if room has no mic track", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - // Remove mic track before teardown - mockRoom.localParticipant.getTrackPublication.mockReturnValue(undefined); - expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); - }); - - it("teardown does not crash if mic track has no sender", () => { - const roomWithNoSender = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - mediaStreamTrack: { id: "track" }, - sender: undefined, - getProcessor: vi.fn(), - }, - }), - }, - } as any; - pipeline.setRoom(roomWithNoSender); - pipeline.setupAudioPipeline(); - expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); - }); - - it("setupAudioPipeline tears down existing pipeline first", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(true); - - // Second setup should tear down the first - pipeline.setupAudioPipeline(); - expect(pipeline.isActive).toBe(true); - expect(mockGainNode.disconnect).toHaveBeenCalled(); - }); - - it("updatePipelineGain applies effective gain when active", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - pipeline.setInputVolume(50); - - expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled(); - const call = - mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; - expect(call[0]).toBe(0.5); // inputGain = 50/100 = 0.5, not vadGated - }); - - it("updatePipelineGain sets gain to 0 when VAD is gated", () => { - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - // Force VAD gated - (pipeline as any).vadGated = true; - pipeline.updatePipelineGain(); - - const call = - mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; - expect(call[0]).toBe(0); - }); - - it("handles AudioContext constructor failure gracefully", () => { - vi.stubGlobal( - "AudioContext", - vi.fn(() => { - throw new Error("AudioContext not supported"); - }), - ); - pipeline.setRoom(mockRoom); - // Should not throw - expect(() => pipeline.setupAudioPipeline()).not.toThrow(); - expect(pipeline.isActive).toBe(false); - }); - }); - - // --- Noise suppressor with track --- - - describe("applyNoiseSuppressor with track", () => { - it("does nothing when track already has a processor", async () => { - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - getProcessor: vi.fn().mockReturnValue({}), // Already has processor - setProcessor: vi.fn(), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - await pipeline.applyNoiseSuppressor(); - expect( - mockRoom.localParticipant.getTrackPublication().track.setProcessor, - ).not.toHaveBeenCalled(); - }); - - it("attaches processor when track has none", async () => { - const setProcessor = vi.fn().mockResolvedValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - getProcessor: vi.fn().mockReturnValue(undefined), - setProcessor, - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - await pipeline.applyNoiseSuppressor(); - expect(setProcessor).toHaveBeenCalled(); - }); - }); - - describe("removeNoiseSuppressor with track", () => { - it("does nothing when track has no processor", async () => { - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - getProcessor: vi.fn().mockReturnValue(undefined), - stopProcessor: vi.fn(), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - await pipeline.removeNoiseSuppressor(); - expect( - mockRoom.localParticipant.getTrackPublication().track.stopProcessor, - ).not.toHaveBeenCalled(); - }); - - it("removes processor when track has one", async () => { - const stopProcessor = vi.fn().mockResolvedValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - getProcessor: vi.fn().mockReturnValue({}), - stopProcessor, - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - await pipeline.removeNoiseSuppressor(); - expect(stopProcessor).toHaveBeenCalled(); - }); - - it("does nothing when track is undefined", async () => { - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ track: undefined }), - }, - } as any; - pipeline.setRoom(mockRoom); - await expect(pipeline.removeNoiseSuppressor()).resolves.toBeUndefined(); - }); - }); - - describe("setVoiceSensitivity edge cases", () => { - it("sensitivity 100 ungates if previously gated", () => { - (pipeline as any).vadGated = true; - pipeline.setVoiceSensitivity(100); - expect(pipeline.isVadGated).toBe(false); - }); - - it("sensitivity below 100 does not change gated state without active pipeline", () => { - pipeline.setVoiceSensitivity(50); - // No crash, no active pipeline to start VAD on - expect(pipeline.isVadGated).toBe(false); - }); - }); - - describe("VAD worklet path", () => { - let mockGainNode: any; - let mockAnalyserNode: any; - let mockDestNode: any; - let mockSourceNode: any; - let mockAudioCtx: any; - let mockRoom: any; - - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - function setupPipelineWithWorklet(workletBehavior: "success" | "fail"): void { - mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - mockAnalyserNode = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }; - mockDestNode = { - stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) }, - disconnect: vi.fn(), - }; - mockSourceNode = { connect: vi.fn() }; - mockAudioCtx = { - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode), - createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode), - createGain: vi.fn().mockReturnValue(mockGainNode), - createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { - addModule: - workletBehavior === "success" - ? vi.fn().mockResolvedValue(undefined) - : vi.fn().mockRejectedValue(new Error("no worklet")), - }, - }; - - // Mock AudioWorkletNode - vi.stubGlobal( - "AudioWorkletNode", - vi.fn().mockImplementation(() => ({ - port: { - postMessage: vi.fn(), - onmessage: null as ((event: MessageEvent) => void) | null, - }, - connect: vi.fn(), - disconnect: vi.fn(), - })), - ); - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - mediaStreamTrack: { id: "track" }, - sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), - setProcessor: vi.fn(), - stopProcessor: vi.fn(), - }, - }), - }, - }; - - // Set sensitivity < 100 so VAD polling starts - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "voiceSensitivity") return 50; - if (key === "inputVolume") return 100; - return defaultVal; - }); - } - - it("starts VAD worklet when AudioWorklet addModule succeeds", async () => { - setupPipelineWithWorklet("success"); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - // Wait for the async addModule to resolve - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - }); - - it("falls back to setTimeout VAD when AudioWorklet addModule fails", async () => { - setupPipelineWithWorklet("fail"); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.waitFor(() => { - // After worklet failure, falls back to setTimeout - expect(pipeline.vadUsingWorklet).toBe(false); - }); - }); - - it("worklet gate message toggles VAD gate", async () => { - setupPipelineWithWorklet("success"); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - - // Get the AudioWorkletNode mock and simulate a gate message - const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; - const workletInstance = WorkletNodeConstructor.mock.results[0].value; - - // Simulate gate message - workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); - expect(pipeline.isVadGated).toBe(true); - - workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any); - expect(pipeline.isVadGated).toBe(false); - }); - - it("worklet rms message updates lastVadRms", async () => { - setupPipelineWithWorklet("success"); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - - const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; - const workletInstance = WorkletNodeConstructor.mock.results[0].value; - - workletInstance.port.onmessage({ data: { type: "rms", value: 0.42 } } as any); - expect(pipeline.lastVadRms).toBe(0.42); - }); - - it("stopVadPolling disconnects worklet node", async () => { - setupPipelineWithWorklet("success"); - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - - const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; - const workletInstance = WorkletNodeConstructor.mock.results[0].value; - - pipeline.stopVadPolling(); - - expect(workletInstance.port.postMessage).toHaveBeenCalledWith({ type: "stop" }); - expect(workletInstance.disconnect).toHaveBeenCalled(); - expect(pipeline.vadUsingWorklet).toBe(false); - }); - - it("falls back to setTimeout when AudioWorkletNode constructor throws", async () => { - setupPipelineWithWorklet("success"); - // Override AudioWorkletNode to throw - vi.stubGlobal( - "AudioWorkletNode", - vi.fn().mockImplementation(() => { - throw new Error("AudioWorkletNode not supported"); - }), - ); - - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.waitFor(() => { - // Should have fallen back to setTimeout - expect(pipeline.vadUsingWorklet).toBe(false); - }); - }); - }); - - describe("VAD fallback polling", () => { - afterEach(() => { - // Stop VAD first to clear the setTimeout chain before teardown - pipeline.stopVadPolling(); - pipeline.teardownAudioPipeline(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("gates audio after sustained silence", async () => { - vi.useFakeTimers(); - const dataArray = new Float32Array(2048); - // Fill with silence - dataArray.fill(0); - - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - arr.set(dataArray); - }), - }; - 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: "track" }]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - }; - - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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: "track" }, - sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), - setProcessor: vi.fn(), - stopProcessor: vi.fn(), - }, - }), - }, - } as any; - - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - // Wait for worklet to fail and fallback to start - await vi.advanceTimersByTimeAsync(100); - - // Run enough frames to pass startup grace (30 frames * 16ms = 480ms) - // and then enough silent frames to trigger gate (12 frames * 16ms = 192ms) - await vi.advanceTimersByTimeAsync(1200); - - expect(pipeline.isVadGated).toBe(true); - }); - - it("ungates audio after speech is detected", async () => { - vi.useFakeTimers(); - let isSilent = true; - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - if (isSilent) { - arr.fill(0); - } else { - // Fill with loud signal - for (let i = 0; i < arr.length; i++) arr[i] = 0.5; - } - }), - }; - 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: "track" }]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - }; - - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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: "track" }, - sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), - setProcessor: vi.fn(), - stopProcessor: vi.fn(), - }, - }), - }, - } as any; - - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); - - // Gate first with silence - await vi.advanceTimersByTimeAsync(1200); - expect(pipeline.isVadGated).toBe(true); - - // Now simulate speech - isSilent = false; - await vi.advanceTimersByTimeAsync(200); - expect(pipeline.isVadGated).toBe(false); - }); - }); - - // --- Mutation-killing tests: boundary conditions, arithmetic, boolean logic --- - - describe("setInputVolume boundary and arithmetic precision", () => { - it("volume 0 produces inputGain exactly 0", () => { - pipeline.setInputVolume(0); - expect(pipeline.inputGain).toBe(0); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); - }); - - it("volume 200 produces inputGain exactly 2.0", () => { - pipeline.setInputVolume(200); - expect(pipeline.inputGain).toBe(2.0); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); - }); - - it("volume 100 produces inputGain exactly 1.0", () => { - pipeline.setInputVolume(100); - expect(pipeline.inputGain).toBe(1.0); - }); - - it("volume 1 produces inputGain 0.01", () => { - pipeline.setInputVolume(1); - expect(pipeline.inputGain).toBeCloseTo(0.01, 5); - }); - - it("negative volume clamps to 0 (not negative)", () => { - pipeline.setInputVolume(-100); - expect(pipeline.inputGain).toBe(0); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); - }); - - it("volume above 200 clamps to 200 (not raw value)", () => { - pipeline.setInputVolume(500); - expect(pipeline.inputGain).toBe(2.0); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); - }); - - it("volume exactly at lower boundary (0) is saved as 0, not clamped further", () => { - pipeline.setInputVolume(0); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0); - }); - - it("volume exactly at upper boundary (200) is saved as 200, not clamped further", () => { - pipeline.setInputVolume(200); - expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200); - }); - }); - - describe("setVoiceSensitivity boundary and arithmetic precision", () => { - it("sensitivity 0 clamps to 0 and saves", () => { - pipeline.setVoiceSensitivity(0); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); - }); - - it("sensitivity exactly 100 saves 100", () => { - pipeline.setVoiceSensitivity(100); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); - }); - - it("sensitivity exactly 99 saves 99 (below 100 threshold)", () => { - pipeline.setVoiceSensitivity(99); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 99); - }); - - it("sensitivity above 100 clamps to 100", () => { - pipeline.setVoiceSensitivity(200); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100); - }); - - it("sensitivity below 0 clamps to 0", () => { - pipeline.setVoiceSensitivity(-50); - expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0); - }); - - it("sensitivity 100 does NOT ungate when already ungated", () => { - // vadGated is false by default; sensitivity 100 should not crash or change state - expect(pipeline.isVadGated).toBe(false); - pipeline.setVoiceSensitivity(100); - expect(pipeline.isVadGated).toBe(false); - }); - - it("sensitivity < 100 calls stopVadPolling which ungates, then restarts polling", () => { - (pipeline as any).vadGated = true; - // setVoiceSensitivity calls stopVadPolling() first, which ungates - pipeline.setVoiceSensitivity(99); - // stopVadPolling always ungates if gated - expect(pipeline.isVadGated).toBe(false); - }); - - it("sensitivity >= 100 ungates immediately without starting VAD", () => { - (pipeline as any).vadGated = true; - pipeline.setVoiceSensitivity(100); - expect(pipeline.isVadGated).toBe(false); - }); - }); - - describe("updatePipelineGain effective gain logic", () => { - let mockGainNode: any; - let mockAudioCtx: any; - - beforeEach(() => { - mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - mockAudioCtx = { - currentTime: 0.5, - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue(mockGainNode), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) }, - disconnect: vi.fn(), - }), - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - }); - - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("uses setTargetAtTime with smoothing constant 0.015", () => { - 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(); - mockGainNode.gain.setTargetAtTime.mockClear(); - - pipeline.setInputVolume(80); - const lastCall = - mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; - expect(lastCall[2]).toBe(0.015); // smoothing time constant - }); - - it("uses ctx.currentTime as the start time for setTargetAtTime", () => { - 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(); - mockGainNode.gain.setTargetAtTime.mockClear(); - - pipeline.setInputVolume(60); - const lastCall = - mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; - expect(lastCall[1]).toBe(0.5); // ctx.currentTime - }); - - it("gain is currentInputGain when not vadGated", () => { - 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(); - pipeline.setInputVolume(130); - mockGainNode.gain.setTargetAtTime.mockClear(); - - pipeline.updatePipelineGain(); - const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0]; - expect(lastCall[0]).toBe(1.3); // 130 / 100 - }); - - it("gain is exactly 0 when vadGated, regardless of inputGain", () => { - 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(); - pipeline.setInputVolume(200); - (pipeline as any).vadGated = true; - mockGainNode.gain.setTargetAtTime.mockClear(); - - pipeline.updatePipelineGain(); - const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0]; - expect(lastCall[0]).toBe(0); - }); - - it("does nothing when audioPipelineGain is null but ctx is not", () => { - // Set pipeline state to have ctx but no gain — simulates partial teardown - (pipeline as any).audioPipelineCtx = mockAudioCtx; - (pipeline as any).audioPipelineGain = null; - mockGainNode.gain.setTargetAtTime.mockClear(); - pipeline.updatePipelineGain(); - expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); - }); - - it("does nothing when audioPipelineCtx is null but gain is not", () => { - (pipeline as any).audioPipelineGain = mockGainNode; - (pipeline as any).audioPipelineCtx = null; - mockGainNode.gain.setTargetAtTime.mockClear(); - pipeline.updatePipelineGain(); - expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); - }); - }); - - describe("setupAudioPipeline AudioContext configuration", () => { - let mockAudioCtx: any; - - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("creates AudioContext with sampleRate 48000", () => { - const AudioContextSpy = vi.fn().mockReturnValue({ - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - 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().mockRejectedValue(new Error("no")) }, - }); - vi.stubGlobal("AudioContext", AudioContextSpy); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - expect(AudioContextSpy).toHaveBeenCalledWith({ sampleRate: 48000 }); - }); - - it("sets analyser fftSize to 2048", () => { - const mockAnalyser = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }; - mockAudioCtx = { - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue(mockAnalyser), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - expect(mockAnalyser.fftSize).toBe(2048); - }); - - it("sets analyser smoothingTimeConstant to 0.3", () => { - const mockAnalyser = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }; - mockAudioCtx = { - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue(mockAnalyser), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - expect(mockAnalyser.smoothingTimeConstant).toBe(0.3); - }); - - it("calls ctx.resume() during setup", () => { - mockAudioCtx = { - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - expect(mockAudioCtx.resume).toHaveBeenCalled(); - }); - }); - - describe("teardownAudioPipeline increments generation", () => { - it("increments _pipelineGeneration on each teardown", () => { - const gen0 = (pipeline as any)._pipelineGeneration; - pipeline.teardownAudioPipeline(); - expect((pipeline as any)._pipelineGeneration).toBe(gen0 + 1); - pipeline.teardownAudioPipeline(); - expect((pipeline as any)._pipelineGeneration).toBe(gen0 + 2); - }); - }); - - describe("startVadPolling threshold calculation and sensitivity guard", () => { - let mockAnalyser: any; - let mockGainNode: any; - let mockAudioCtx: any; - - afterEach(() => { - pipeline.stopVadPolling(); - pipeline.teardownAudioPipeline(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - function setupPipelineForVad(sensitivity: number): void { - mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), - }; - mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "voiceSensitivity") return sensitivity; - 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); - } - - it("sensitivity 100 prevents VAD from starting (no polling)", async () => { - vi.useFakeTimers(); - setupPipelineForVad(100); - pipeline.setupAudioPipeline(); - - // Wait for async paths to settle - await vi.advanceTimersByTimeAsync(200); - - // VAD should not be running - no gate should happen even after lots of silence - await vi.advanceTimersByTimeAsync(2000); - expect(pipeline.isVadGated).toBe(false); - }); - - it("sensitivity 99 allows VAD to start and eventually gate silence", async () => { - vi.useFakeTimers(); - setupPipelineForVad(99); - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); // worklet fails, fallback starts - await vi.advanceTimersByTimeAsync(1200); // startup grace + gate frames - expect(pipeline.isVadGated).toBe(true); - }); - - it("sensitivity 0 produces high threshold that gates easily", async () => { - vi.useFakeTimers(); - setupPipelineForVad(0); - // threshold = ((100 - 0) / 100) * 0.1 = 0.1 - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); - await vi.advanceTimersByTimeAsync(1200); - expect(pipeline.isVadGated).toBe(true); - }); - - it("sensitivity 50 produces threshold 0.05", async () => { - vi.useFakeTimers(); - setupPipelineForVad(50); - // threshold = ((100 - 50) / 100) * 0.1 = 0.05 - // silence (rms=0) < 0.05, so should gate - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); - await vi.advanceTimersByTimeAsync(1200); - expect(pipeline.isVadGated).toBe(true); - }); - }); - - describe("VAD fallback frame counters and RMS reporting", () => { - afterEach(() => { - pipeline.stopVadPolling(); - pipeline.teardownAudioPipeline(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - function setupFallbackPipeline(): { mockAnalyser: any; mockGainNode: any } { - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - // Moderate signal — above threshold so we can test non-gating - for (let i = 0; i < arr.length; i++) arr[i] = 0.3; - }), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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); - return { mockAnalyser, mockGainNode }; - } - - it("updates _lastVadRms every 3 frames (frameCounter >= 3 resets)", async () => { - vi.useFakeTimers(); - setupFallbackPipeline(); - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); // worklet fails - // RMS for constant 0.3 signal: sqrt(0.09) = 0.3 - // After startup grace (30 frames), frameCounter increments 1,2,3 -> reset + update - await vi.advanceTimersByTimeAsync(1000); - - // lastVadRms should have been updated to ~0.3 (the RMS of constant 0.3 signal) - expect(pipeline.lastVadRms).toBeGreaterThan(0); - expect(pipeline.lastVadRms).toBeCloseTo(0.3, 1); - }); - - it("does not gate when rms is above threshold (speech frames accumulate)", async () => { - vi.useFakeTimers(); - setupFallbackPipeline(); // signal at 0.3, threshold = 0.05 - pipeline.setupAudioPipeline(); - - await vi.advanceTimersByTimeAsync(100); - await vi.advanceTimersByTimeAsync(1200); - // rms 0.3 > threshold 0.05, so silentFrames never accumulate, no gating - expect(pipeline.isVadGated).toBe(false); - }); - - it("gate requires exactly GATE_ON_FRAMES (12) consecutive silent frames", async () => { - vi.useFakeTimers(); - let frameCount = 0; - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - frameCount++; - // After startup grace (30 frames), be silent for exactly 11 frames, then loud - if (frameCount > 30 && frameCount <= 41) { - arr.fill(0); // silent - } else if (frameCount === 42) { - for (let i = 0; i < arr.length; i++) arr[i] = 0.5; // loud — resets counter - } else if (frameCount > 42) { - arr.fill(0); // silent again — needs 12 more to gate - } else { - arr.fill(0); // startup grace - } - }), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - await vi.advanceTimersByTimeAsync(100); // worklet fails - // Run through startup (30 frames) + 11 silent + 1 loud = 42 frames * 16ms = 672ms - await vi.advanceTimersByTimeAsync(700); - // After 11 silent frames then 1 loud: should NOT be gated yet (needs 12 consecutive) - // The loud frame resets silentFrames to 0 - - // Now run 12 more silent frames to trigger gating - await vi.advanceTimersByTimeAsync(250); // 12+ frames * 16ms - expect(pipeline.isVadGated).toBe(true); - }); - - it("ungate requires GATE_OFF_FRAMES (2) consecutive speech frames after gating", async () => { - vi.useFakeTimers(); - let isSilent = true; - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - if (isSilent) { - arr.fill(0); - } else { - for (let i = 0; i < arr.length; i++) arr[i] = 0.5; - } - }), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - // Wait for worklet to fail and fallback to start - await vi.advanceTimersByTimeAsync(100); - // Gate with silence: startup grace (30*16=480ms) + gate frames (12*16=192ms) - await vi.advanceTimersByTimeAsync(1200); - expect(pipeline.isVadGated).toBe(true); - - // Switch to speech — need 2 consecutive speech frames (GATE_OFF_FRAMES) to ungate - isSilent = false; - await vi.advanceTimersByTimeAsync(200); // 2+ frames * 16ms - expect(pipeline.isVadGated).toBe(false); - }); - - it("startup grace period skips first 30 frames without gating", async () => { - vi.useFakeTimers(); - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { - arr.fill(0); // always silent - }), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - await vi.advanceTimersByTimeAsync(100); // worklet fails - // Only run startup grace period: 30 frames * 16ms = 480ms - // Gate needs 12 more frames after grace - await vi.advanceTimersByTimeAsync(480); - // During grace period, no gating should occur despite silence - // But after grace + ~12 frames (192ms), gating occurs - // So at ~580ms from fallback start, should not yet be gated - // (480ms grace + only a few post-grace frames) - // Let's check at exactly the grace boundary - expect(pipeline.isVadGated).toBe(false); - - // Now advance past grace + 12 gate frames - await vi.advanceTimersByTimeAsync(300); - expect(pipeline.isVadGated).toBe(true); - }); - }); - - describe("VAD fallback stops when analyser is torn down mid-poll", () => { - afterEach(() => { - pipeline.stopVadPolling(); - pipeline.teardownAudioPipeline(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("poll stops iterating when analyser becomes null", async () => { - vi.useFakeTimers(); - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - await vi.advanceTimersByTimeAsync(100); - - // Null out the analyser mid-poll - (pipeline as any).audioPipelineAnalyser = null; - const callsBefore = mockAnalyser.getFloatTimeDomainData.mock.calls.length; - - await vi.advanceTimersByTimeAsync(200); - // No new calls should happen since analyser is null - expect(mockAnalyser.getFloatTimeDomainData.mock.calls.length).toBe(callsBefore); - }); - }); - - describe("pipeline generation prevents stale async results", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("discards worklet addModule result if pipeline torn down during load", async () => { - let resolveAddModule: () => void; - const addModulePromise = new Promise((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(); - - // Teardown increments generation, making the pending addModule stale - pipeline.teardownAudioPipeline(); - - // Now resolve addModule — should be discarded because generation changed - resolveAddModule!(); - await addModulePromise; - - // Yield to microtasks - await new Promise((r) => setTimeout(r, 0)); - - // Worklet should NOT have been started (generation mismatch) - expect(pipeline.vadUsingWorklet).toBe(false); - }); - }); - - describe("worklet gate message deduplication", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("does not call updatePipelineGain when gate state unchanged", async () => { - const mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - const mockAnalyser = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: 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().mockResolvedValue(undefined) }, - }; - 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(); - - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - - const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode; - const workletInstance = WorkletNodeConstructor.mock.results[0].value; - mockGainNode.gain.setTargetAtTime.mockClear(); - - // Send gate=false when already ungated — should NOT trigger updatePipelineGain - workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any); - expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); - - // Send gate=true — should trigger - workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); - expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled(); - mockGainNode.gain.setTargetAtTime.mockClear(); - - // Send gate=true again — should NOT trigger (already gated) - workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any); - expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled(); - }); - }); - - describe("worklet sends config with threshold", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("posts config message with correct threshold to worklet port", async () => { - const mockGainNode = { - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - const mockAnalyser = { - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }; - const postMessageSpy = 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().mockResolvedValue(undefined) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - vi.stubGlobal( - "AudioWorkletNode", - vi.fn().mockImplementation(() => ({ - port: { postMessage: postMessageSpy, onmessage: null }, - connect: vi.fn(), - disconnect: vi.fn(), - })), - ); - - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "voiceSensitivity") return 50; // threshold = ((100-50)/100)*0.1 = 0.05 - 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(); - - await vi.waitFor(() => { - expect(pipeline.vadUsingWorklet).toBe(true); - }); - - expect(postMessageSpy).toHaveBeenCalledWith({ type: "config", threshold: 0.05 }); - }); - }); - - describe("stopVadPolling clears vadTimer", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("clears the setTimeout-based vadTimer on stop", async () => { - vi.useFakeTimers(); - const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); - - const mockAnalyser = { - fftSize: 2048, - smoothingTimeConstant: 0.3, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)), - }; - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - 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(); - - await vi.advanceTimersByTimeAsync(100); // fallback starts - clearTimeoutSpy.mockClear(); - - pipeline.stopVadPolling(); - expect(clearTimeoutSpy).toHaveBeenCalled(); - - clearTimeoutSpy.mockRestore(); - }); - }); - - describe("teardownAudioPipeline handles replaceTrack failure gracefully", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("does not throw when sender.replaceTrack rejects during teardown", () => { - 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({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - 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().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - mediaStreamTrack: { id: "t" }, - sender: { replaceTrack: vi.fn().mockRejectedValue(new Error("fail")) }, - getProcessor: vi.fn(), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - pipeline.setupAudioPipeline(); - - expect(() => pipeline.teardownAudioPipeline()).not.toThrow(); - expect(pipeline.isActive).toBe(false); - }); - }); - - describe("setInputVolume calls updatePipelineGain", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("calls updatePipelineGain which is no-op without active pipeline", () => { - // No active pipeline — updatePipelineGain should not throw - pipeline.setInputVolume(50); - expect(pipeline.inputGain).toBe(0.5); - expect(pipeline.gainValue).toBeNull(); // no pipeline - }); - }); - - describe("setupAudioPipeline sender.replaceTrack failure during setup", () => { - afterEach(() => { - pipeline.teardownAudioPipeline(); - vi.unstubAllGlobals(); - }); - - it("catches replaceTrack rejection during setup without crashing", () => { - 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({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue(mockGainNode), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted" }]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) }, - }; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - mediaStreamTrack: { id: "t" }, - sender: { replaceTrack: vi.fn().mockRejectedValue(new Error("replace fail")) }, - getProcessor: vi.fn(), - }, - }), - }, - } as any; - pipeline.setRoom(mockRoom); - expect(() => pipeline.setupAudioPipeline()).not.toThrow(); - expect(pipeline.isActive).toBe(true); - }); - }); - - describe("reapplyAudioProcessing success path", () => { - it("restarts track, rebuilds pipeline, and applies enhanced NS", async () => { - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "enhancedNoiseSuppression") return true; - if (key === "echoCancellation") return true; - if (key === "noiseSuppression") return true; - if (key === "autoGainControl") return true; - return defaultVal; - }); - - const restartTrack = vi.fn().mockResolvedValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - restartTrack, - mediaStreamTrack: { id: "track" }, - sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn().mockReturnValue(undefined), - setProcessor: vi.fn().mockResolvedValue(undefined), - }, - }), - }, - } as any; - - // Stub AudioContext for setupAudioPipeline called internally - vi.stubGlobal( - "AudioContext", - vi.fn().mockReturnValue({ - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - }), - ); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - pipeline.setRoom(mockRoom); - await pipeline.reapplyAudioProcessing(); - - expect(restartTrack).toHaveBeenCalledWith({ - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }); - }); - - it("removes noise suppressor when enhanced NS is disabled", async () => { - mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { - if (key === "enhancedNoiseSuppression") return false; - if (key === "echoCancellation") return true; - if (key === "noiseSuppression") return true; - if (key === "autoGainControl") return true; - return defaultVal; - }); - - const stopProcessor = vi.fn().mockResolvedValue(undefined); - const restartTrack = vi.fn().mockResolvedValue(undefined); - const mockRoom = { - localParticipant: { - getTrackPublication: vi.fn().mockReturnValue({ - track: { - restartTrack, - mediaStreamTrack: { id: "track" }, - sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn().mockReturnValue({}), // has a processor - setProcessor: vi.fn().mockResolvedValue(undefined), - stopProcessor, - }, - }), - }, - } as any; - - vi.stubGlobal( - "AudioContext", - vi.fn().mockReturnValue({ - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, - smoothingTimeConstant: 0, - connect: vi.fn(), - disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), - }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), - disconnect: vi.fn(), - }), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - }), - ); - vi.stubGlobal( - "MediaStream", - vi.fn().mockImplementation(() => ({})), - ); - - pipeline.setRoom(mockRoom); - await pipeline.reapplyAudioProcessing(); - - expect(restartTrack).toHaveBeenCalled(); - expect(stopProcessor).toHaveBeenCalled(); - }); - }); -}); diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts index 9d692f43..31e78b25 100644 --- a/Client/tauri-client/tests/unit/auth.store.test.ts +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -7,8 +7,18 @@ import { getCurrentUser, updateUser, } from "../../src/stores/auth.store"; +import { resetVoiceStore, joinVoiceChannel, setVoiceStatus } from "../../src/stores/voice.store"; +import { leaveVoice } from "@lib/livekitSession"; import type { UserWithRole } 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. +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), +})); + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + const TEST_USER: UserWithRole = { id: 42, username: "testuser", @@ -204,6 +214,30 @@ describe("auth store", () => { }); }); + // clearAuth voice-session cleanup (regression: don't force-load the LiveKit + // chunk on every logout/401 for a text-only user). + describe("clearAuth voice cleanup", () => { + beforeEach(() => { + resetVoiceStore(); + vi.mocked(leaveVoice).mockClear(); + }); + + it("does NOT load livekitSession when there is no active voice session", async () => { + // Voice store is idle (currentChannelId null, voiceStatus "idle"). + clearAuth(); + await flushMicrotasks(); + expect(leaveVoice).not.toHaveBeenCalled(); + }); + + it("leaves voice when a voice session is active", async () => { + joinVoiceChannel(7); // currentChannelId=7, voiceStatus="joining" + setVoiceStatus("connected"); + clearAuth(); + await flushMicrotasks(); + expect(leaveVoice).toHaveBeenCalledWith(false); + }); + }); + // 6. Subscribe receives updates on setAuth/clearAuth describe("subscribe", () => { it("notifies on setAuth", () => { diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 85b2c631..43e032b8 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -102,6 +102,8 @@ const { mockSetMessagePinned, mockAddOptimistic, mockMarkSendFailed, mockRemoveO mockRemoveOptimistic: vi.fn(), })); +const { mockRole } = vi.hoisted(() => ({ mockRole: { value: "member" } })); + vi.mock("@stores/messages.store", () => ({ getChannelMessages: mockGetChannelMessages, setMessagePinned: mockSetMessagePinned, @@ -112,7 +114,7 @@ vi.mock("@stores/messages.store", () => ({ vi.mock("@stores/auth.store", () => ({ authStore: { - getState: () => ({ user: { id: 1, username: "tester", avatar: null } }), + getState: () => ({ user: { id: 1, username: "tester", avatar: null, role: mockRole.value } }), }, })); @@ -172,6 +174,7 @@ vi.mock("@stores/blocks.store", () => ({ import { createChannelController } from "../../src/pages/main-page/ChannelController"; import type { ChannelControllerOptions } from "../../src/pages/main-page/ChannelController"; import { setConnectionStatus } from "@stores/ui.store"; +import { channelsStore, setChannels, setActiveChannel, setRoles } from "@stores/channels.store"; // --------------------------------------------------------------------------- // Helpers @@ -191,6 +194,8 @@ function makeOpts(overrides: Partial = {}): ChannelCon send: vi.fn(), getState: vi.fn(() => "connected"), onStateChange: vi.fn(() => vi.fn()), + // The composer subscribes to chat_send_ok / error to drive slow mode. + on: vi.fn(() => vi.fn()), } as unknown as ChannelControllerOptions["ws"], api: { uploadFile: vi.fn().mockResolvedValue({ id: 1, url: "/f/1", filename: "f.txt" }), @@ -865,6 +870,123 @@ describe("createChannelController", () => { }); }); + describe("slow mode", () => { + /** Pull a ws.on handler registered by the controller. */ + function wsHandler(opts: ChannelControllerOptions, event: string): (payload: never) => void { + const calls = (opts.ws.on as ReturnType).mock.calls; + const entry = calls.find((c) => c[0] === event); + expect(entry).toBeDefined(); + return entry![1] as (payload: never) => void; + } + + function seedChannel(slowMode: number): void { + setChannels([ + { + id: 42, + name: "general", + type: "text", + category: null, + position: 0, + can_send: true, + slow_mode: slowMode, + }, + ]); + setActiveChannel(42); + } + + beforeEach(() => { + mockRole.value = "member"; + setRoles([{ id: 4, name: "member", color: null, permissions: 0 }]); + setConnectionStatus("connected"); + }); + + it("disables the composer for the cooldown after an accepted send", () => { + vi.useFakeTimers(); + try { + seedChannel(5); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + + wsHandler(opts, "chat_send_ok")({} as never); + expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 5s"); + + vi.advanceTimersByTime(3000); + expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 2s"); + + vi.advanceTimersByTime(2000); + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves the composer alone in a channel without slow mode", () => { + seedChannel(0); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + + wsHandler(opts, "chat_send_ok")({} as never); + + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + }); + + it("restarts the cooldown when the server refuses with SLOW_MODE", () => { + vi.useFakeTimers(); + try { + seedChannel(10); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + + wsHandler(opts, "error")({ code: "SLOW_MODE", message: "slow mode" } as never); + expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 10s"); + + // An unrelated error must not gate the composer. + vi.advanceTimersByTime(10_000); + mockSetDisabled.mockClear(); + wsHandler(opts, "error")({ code: "FORBIDDEN", message: "nope" } as never); + expect(mockSetDisabled).not.toHaveBeenCalledWith(expect.stringContaining("Slow mode")); + } finally { + vi.useRealTimers(); + } + }); + + it("does not gate a moderator, who bypasses slow mode server-side", () => { + seedChannel(5); + mockRole.value = "moderator"; + setRoles([{ id: 3, name: "moderator", color: null, permissions: 0x10000 }]); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + + wsHandler(opts, "chat_send_ok")({} as never); + + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + }); + + it("stops the countdown when the channel unmounts", () => { + vi.useFakeTimers(); + try { + seedChannel(5); + const opts = makeOpts(); + const ctrl = createChannelController(opts); + ctrl.mountChannel(42, "general"); + wsHandler(opts, "chat_send_ok")({} as never); + + ctrl.destroyChannel(); + mockSetDisabled.mockClear(); + vi.advanceTimersByTime(5000); + + expect(mockSetDisabled).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); + describe("DM composer block gating", () => { function mountDm(reason: string | null): void { mockDmStoreGetState.mockReturnValue({ diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 8db8556b..f5d407de 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -655,6 +655,60 @@ describe("ChannelSidebar", () => { expect(updatedRow!.classList.contains("speaking")).toBe(true); }); + it("speaking patch keeps the exact row element (cached map, no rebuild)", () => { + setChannels(testChannels); + updateVoiceState({ + channel_id: 3, + user_id: 61, + username: "Talker2", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + sidebar.mount(container); + + const rowBefore = container.querySelector('.voice-user-item[data-voice-uid="61"]'); + expect(rowBefore).not.toBeNull(); + + // speaking-only flip → patched via the cached row map, not re-rendered + updateVoiceState({ + channel_id: 3, + user_id: 61, + username: "Talker2", + muted: false, + deafened: false, + speaking: true, + camera: false, + screenshare: false, + }); + voiceStore.flush(); + + const rowAfter = container.querySelector('.voice-user-item[data-voice-uid="61"]'); + expect(rowAfter).toBe(rowBefore); // same element instance + expect(rowAfter!.classList.contains("speaking")).toBe(true); + + // …and a structural change (mute) still re-renders with a fresh row. + updateVoiceState({ + channel_id: 3, + user_id: 61, + username: "Talker2", + muted: true, + deafened: false, + speaking: true, + camera: false, + screenshare: false, + }); + voiceStore.flush(); + + const rowRebuilt = container.querySelector('.voice-user-item[data-voice-uid="61"]'); + expect(rowRebuilt).not.toBe(rowBefore); + expect(rowRebuilt!.querySelector(".vu-muted")).not.toBeNull(); + // The rebuilt row keeps the speaking class (patch runs after re-render). + expect(rowRebuilt!.classList.contains("speaking")).toBe(true); + }); + // ── Voice user avatar ── it("renders first-letter avatar with deterministic color for voice user", () => { diff --git a/Client/tauri-client/tests/unit/channels.store.test.ts b/Client/tauri-client/tests/unit/channels.store.test.ts index cba6e2a0..dd1fe970 100644 --- a/Client/tauri-client/tests/unit/channels.store.test.ts +++ b/Client/tauri-client/tests/unit/channels.store.test.ts @@ -74,6 +74,7 @@ describe("channels store", () => { unreadCount: 3, lastMessageId: 100, canSend: true, + slowMode: 0, }); const voice = state.channels.get(2); @@ -86,6 +87,7 @@ describe("channels store", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); }); @@ -123,6 +125,7 @@ describe("channels store", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); }); @@ -286,6 +289,7 @@ describe("channels store", () => { unreadCount: 0, lastMessageId: 100, canSend: true, + slowMode: 0, }); }); diff --git a/Client/tauri-client/tests/unit/device-manager.test.ts b/Client/tauri-client/tests/unit/device-manager.test.ts index 7ed9a6ae..23c8a62f 100644 --- a/Client/tauri-client/tests/unit/device-manager.test.ts +++ b/Client/tauri-client/tests/unit/device-manager.test.ts @@ -271,6 +271,18 @@ describe("DeviceManager", () => { await dm.switchOutputDevice("device-1"); expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audiooutput", "device-1"); }); + + it("reports a failed switch instead of rejecting into the void", async () => { + // The settings tab calls this as a bare `void` — an unhandled rejection + // would leave the user with a selection that silently never applied. + const onError = vi.fn(); + dm.setOnError(onError); + dm.setRoom(mockRoom); + mockRoom.switchActiveDevice.mockRejectedValueOnce(new Error("setSinkId unsupported")); + + await expect(dm.switchOutputDevice("device-1")).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith("Failed to switch speaker"); + }); }); // ----------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 2d0ceef7..d9ef05c4 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -36,9 +36,7 @@ vi.mock("@lib/identity", () => ({ ensureIdentityKeyPublished: vi.fn(async () => true), })); -import { isVoiceConnected as _isVoiceConnected } from "../../src/lib/livekitSession"; import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity"; -const mockIsVoiceConnected = vi.mocked(_isVoiceConnected); const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished); // Suppress console output @@ -217,6 +215,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1 }); @@ -277,6 +276,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch }; }); @@ -530,6 +530,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch }; }); @@ -558,6 +559,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); ch.set(20, { id: 20, @@ -568,6 +570,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch, activeChannelId: 10 }; }); @@ -590,6 +593,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch, activeChannelId: 10 }; }); @@ -725,13 +729,17 @@ describe("WS Dispatcher", () => { direct_url: "wss://direct.example.com", }); - expect(handleVoiceToken).toHaveBeenCalledWith( - "lk-token", - "wss://livekit.example.com", - 3, - "wss://direct.example.com", - undefined, - ); + // livekitSession is dynamically imported by the handler, so the call + // lands after the import promise resolves. + await vi.waitFor(() => { + expect(handleVoiceToken).toHaveBeenCalledWith( + "lk-token", + "wss://livekit.example.com", + 3, + "wss://direct.example.com", + undefined, + ); + }); }); it("wires server_restart to transient error", () => { @@ -1004,6 +1012,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; }); @@ -1035,6 +1044,7 @@ describe("WS Dispatcher", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; }); @@ -1273,7 +1283,8 @@ describe("WS Dispatcher", () => { }); it("ready sends voice_leave when user appears in voice_states but LiveKit is disconnected", () => { - mockIsVoiceConnected.mockReturnValue(false); + // A fresh reload always starts with an idle voice session — the stale case. + voiceStore.setState((prev) => ({ ...prev, voiceStatus: "idle" })); // Set up auth so the current user ID is 42 authStore.setState(() => ({ @@ -1301,7 +1312,9 @@ describe("WS Dispatcher", () => { }); it("ready does NOT send voice_leave when LiveKit IS connected", () => { - mockIsVoiceConnected.mockReturnValue(true); + // A non-idle voice status means livekitSession is driving a live/pending + // session (the lazily-loaded module's store-backed "connected" flag). + voiceStore.setState((prev) => ({ ...prev, voiceStatus: "connected" })); authStore.setState(() => ({ token: "test-token", @@ -1328,7 +1341,7 @@ describe("WS Dispatcher", () => { }); it("ready does NOT send voice_leave when user is NOT in voice_states", () => { - mockIsVoiceConnected.mockReturnValue(false); + voiceStore.setState((prev) => ({ ...prev, voiceStatus: "idle" })); authStore.setState(() => ({ token: "test-token", diff --git a/Client/tauri-client/tests/unit/drag-reorder.test.ts b/Client/tauri-client/tests/unit/drag-reorder.test.ts index 6a5c090d..558d6939 100644 --- a/Client/tauri-client/tests/unit/drag-reorder.test.ts +++ b/Client/tauri-client/tests/unit/drag-reorder.test.ts @@ -47,6 +47,7 @@ function makeCh(id: number, position: number, name = `ch-${id}`): Channel { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }; } diff --git a/Client/tauri-client/tests/unit/global-keybinds.test.ts b/Client/tauri-client/tests/unit/global-keybinds.test.ts new file mode 100644 index 00000000..3f47d8f3 --- /dev/null +++ b/Client/tauri-client/tests/unit/global-keybinds.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockVoiceGetState } = vi.hoisted(() => ({ + mockVoiceGetState: vi.fn(() => ({ currentChannelId: null as number | null })), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@stores/voice.store", () => ({ + voiceStore: { getState: mockVoiceGetState }, +})); + +const { attachGlobalKeybinds } = await import("../../src/pages/main-page/GlobalKeybinds"); + +function makeHandlers(): { + onSearch: ReturnType; + onToggleMute: ReturnType; + onToggleDeafen: ReturnType; + onToggleCamera: ReturnType; + onUploadFile: ReturnType; +} { + return { + onSearch: vi.fn(), + onToggleMute: vi.fn(), + onToggleDeafen: vi.fn(), + onToggleCamera: vi.fn(), + onUploadFile: vi.fn(), + }; +} + +function press(key: string, opts: Partial = {}): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + ctrlKey: true, + cancelable: true, + ...opts, + }); + document.dispatchEvent(event); + return event; +} + +describe("global keybinds", () => { + let detach: (() => void) | null = null; + + beforeEach(() => { + mockVoiceGetState.mockReturnValue({ currentChannelId: null }); + }); + + afterEach(() => { + detach?.(); + detach = null; + }); + + it("Ctrl+F opens search and swallows the browser default", () => { + const h = makeHandlers(); + detach = attachGlobalKeybinds(h); + + const event = press("f"); + + expect(h.onSearch).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it("Ctrl+U opens the file picker", () => { + const h = makeHandlers(); + detach = attachGlobalKeybinds(h); + + press("u"); + + expect(h.onUploadFile).toHaveBeenCalledOnce(); + }); + + it("ignores voice shortcuts outside a voice channel", () => { + const h = makeHandlers(); + detach = attachGlobalKeybinds(h); + + const mute = press("m"); + const deafen = press("d"); + const camera = press("V", { shiftKey: true }); + + expect(h.onToggleMute).not.toHaveBeenCalled(); + expect(h.onToggleDeafen).not.toHaveBeenCalled(); + expect(h.onToggleCamera).not.toHaveBeenCalled(); + // Untouched keys keep their default behaviour. + expect(mute.defaultPrevented).toBe(false); + expect(deafen.defaultPrevented).toBe(false); + expect(camera.defaultPrevented).toBe(false); + }); + + it("fires voice shortcuts while connected to voice", () => { + mockVoiceGetState.mockReturnValue({ currentChannelId: 7 }); + const h = makeHandlers(); + detach = attachGlobalKeybinds(h); + + press("m"); + press("d"); + // Shift uppercases the key — the handler must not miss it. + press("V", { shiftKey: true }); + + expect(h.onToggleMute).toHaveBeenCalledOnce(); + expect(h.onToggleDeafen).toHaveBeenCalledOnce(); + expect(h.onToggleCamera).toHaveBeenCalledOnce(); + }); + + it("does nothing while suspended (settings overlay open)", () => { + const h = makeHandlers(); + detach = attachGlobalKeybinds({ ...h, isSuspended: () => true }); + + press("f"); + press("u"); + + expect(h.onSearch).not.toHaveBeenCalled(); + expect(h.onUploadFile).not.toHaveBeenCalled(); + }); + + it("ignores plain keys and Alt combos", () => { + const h = makeHandlers(); + detach = attachGlobalKeybinds(h); + + press("f", { ctrlKey: false }); + press("f", { altKey: true }); + + expect(h.onSearch).not.toHaveBeenCalled(); + }); + + it("keeps a handler error from escaping to the document", () => { + const h = makeHandlers(); + h.onSearch.mockImplementation(() => { + throw new Error("boom"); + }); + detach = attachGlobalKeybinds(h); + + expect(() => press("f")).not.toThrow(); + }); + + it("detaching stops the shortcuts", () => { + const h = makeHandlers(); + const stop = attachGlobalKeybinds(h); + stop(); + + press("f"); + + expect(h.onSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/helpers/ws-mocks.ts b/Client/tauri-client/tests/unit/helpers/ws-mocks.ts new file mode 100644 index 00000000..004c1d10 --- /dev/null +++ b/Client/tauri-client/tests/unit/helpers/ws-mocks.ts @@ -0,0 +1,56 @@ +import { vi } from "vitest"; + +/** + * Shared Tauri mocks for the ws client test files (ws-*.test.ts). + * + * vi.mock() is hoisted per test file, so each split file must call + * vi.mock("@tauri-apps/api/core") / vi.mock("@tauri-apps/api/event") itself + * with factories that resolve to the handles exported from this module: + * + * vi.mock("@tauri-apps/api/core", async () => ({ + * invoke: (await import("./helpers/ws-mocks")).mockInvoke, + * })); + * vi.mock("@tauri-apps/api/event", async () => ({ + * listen: (await import("./helpers/ws-mocks")).mockListen, + * })); + */ + +/** Registry of handlers registered through the mocked Tauri listen(). */ +export const eventHandlers = new Map void>>(); + +export const mockInvoke = vi.fn(); + +export const mockListen = vi.fn( + async (event: string, handler: (e: { payload: unknown }) => void) => { + if (!eventHandlers.has(event)) eventHandlers.set(event, []); + eventHandlers.get(event)!.push(handler); + return () => { + const arr = eventHandlers.get(event); + if (arr) { + const idx = arr.indexOf(handler); + if (idx >= 0) arr.splice(idx, 1); + } + }; + }, +); + +// Mock crypto.randomUUID +vi.stubGlobal("crypto", { + randomUUID: () => "test-uuid-1234", +}); + +// Suppress console output +vi.spyOn(console, "debug").mockImplementation(() => {}); +vi.spyOn(console, "info").mockImplementation(() => {}); +vi.spyOn(console, "warn").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +/** Simulate Tauri emitting an event to JS */ +export function emitTauriEvent(event: string, payload: unknown): void { + const handlers = eventHandlers.get(event); + if (handlers) { + for (const h of handlers) { + h({ payload }); + } + } +} diff --git a/Client/tauri-client/tests/unit/invite-manager.test.ts b/Client/tauri-client/tests/unit/invite-manager.test.ts index b1e651fa..25d6ea4c 100644 --- a/Client/tauri-client/tests/unit/invite-manager.test.ts +++ b/Client/tauri-client/tests/unit/invite-manager.test.ts @@ -112,6 +112,52 @@ describe("InviteManager", () => { mgr.destroy?.(); }); + it("only mints one invite per click, even on a double-click", async () => { + let release: ((v: InviteItem) => void) | null = null; + const onCreateInvite = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const opts = makeOptions({ invites: [], onCreateInvite }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement; + createBtn.click(); + expect(createBtn.disabled).toBe(true); + createBtn.click(); + expect(onCreateInvite).toHaveBeenCalledTimes(1); + + release!(makeInvite({ code: "newcode123" })); + await vi.waitFor(() => { + expect(createBtn.disabled).toBe(false); + }); + + mgr.destroy?.(); + }); + + it("disarms the revoke confirm if it is left alone", () => { + vi.useFakeTimers(); + try { + const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + revokeBtn.click(); + vi.advanceTimersByTime(5000); + revokeBtn.click(); + + // The second click re-arms rather than revoking a link the user forgot about. + expect(opts.onRevokeInvite).not.toHaveBeenCalled(); + mgr.destroy?.(); + } finally { + vi.useRealTimers(); + } + }); + it("click revoke calls onRevokeInvite and removes from list on resolve", async () => { const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); const mgr = createInviteManager(opts); @@ -120,6 +166,9 @@ describe("InviteManager", () => { expect(container.querySelectorAll(".invite-item").length).toBe(1); const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + // Revoking kills a live link — first click only arms the confirm. + revokeBtn.click(); + expect(opts.onRevokeInvite).not.toHaveBeenCalled(); revokeBtn.click(); expect(opts.onRevokeInvite).toHaveBeenCalledWith("abc123xyz"); @@ -192,6 +241,7 @@ describe("InviteManager", () => { const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; revokeBtn.click(); + revokeBtn.click(); await vi.waitFor(() => { expect(opts.onError).toHaveBeenCalledWith("Failed to revoke invite"); diff --git a/Client/tauri-client/tests/unit/keybinds-tab.test.ts b/Client/tauri-client/tests/unit/keybinds-tab.test.ts index b791eb27..d3d93844 100644 --- a/Client/tauri-client/tests/unit/keybinds-tab.test.ts +++ b/Client/tauri-client/tests/unit/keybinds-tab.test.ts @@ -213,10 +213,12 @@ describe("KeybindsTab", () => { // --- All keybinds present --- - it("renders Mark as Read, Search Messages, Upload File, Edit Last Message keybinds", () => { + it("renders Close Overlay, Search Messages, Upload File, Edit Last Message keybinds", () => { const el = buildKeybindsTab(new AbortController().signal); const labels = Array.from(el.querySelectorAll(".setting-label")).map((l) => l.textContent); - expect(labels).toContain("Mark as Read"); + // "Mark as Read" used to be listed here with no feature behind it. + expect(labels).not.toContain("Mark as Read"); + expect(labels).toContain("Close Overlay / Cancel"); expect(labels).toContain("Search Messages"); expect(labels).toContain("Upload File"); expect(labels).toContain("Edit Last Message"); diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts new file mode 100644 index 00000000..97e587f7 --- /dev/null +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Mocks must be declared before imports --- +// The full E2EE protocol (announce verification, TOFU pinning, rotation-on-leave, +// timeout paths) is exercised end-to-end through the LiveKitSession facade in +// livekit-session.test.ts. This file is a focused smoke test of the extracted +// E2EEManager module surface. + +const mockSetKey = vi.hoisted(() => vi.fn()); + +vi.mock("livekit-client", () => ({ + ExternalE2EEKeyProvider: vi.fn(() => ({ + setKey: mockSetKey, + getKeys: vi.fn().mockReturnValue([]), + })), +})); + +const mockKeyPair = vi.hoisted(() => ({ + publicKey: { type: "public" } as unknown as CryptoKey, + privateKey: { type: "private" } as unknown as CryptoKey, +})); + +const mockIdentityKeyPair = vi.hoisted(() => ({ + publicKey: { type: "id-public" } as unknown as CryptoKey, + privateKey: { type: "id-private" } as unknown as CryptoKey, +})); + +vi.mock("@lib/e2eeCrypto", () => ({ + generateECDHKeyPair: vi.fn(async () => mockKeyPair), + exportPublicKey: vi.fn(async () => "bW9ja2VwaGVtZXJhbA=="), + importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey), + generateRoomKey: vi.fn(() => new Uint8Array(32)), + roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), + wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })), + unwrapRoomKey: vi.fn(async () => new Uint8Array(32)), + signEphemeralKey: vi.fn(async () => "mock-signature"), + verifyEphemeralKeySignature: vi.fn(async () => true), + importIdentityPublicKey: vi.fn( + async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, + ), + computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"), +})); + +vi.mock("@lib/identity", () => ({ + getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair), + getIdentityPin: vi.fn(async () => null), + storeIdentityPin: vi.fn(async () => true), +})); + +vi.mock("@stores/auth.store", () => ({ + authStore: { getState: vi.fn(() => ({ user: { id: 1 } })) }, +})); + +const mockMembers = vi.hoisted(() => new Map()); + +vi.mock("@stores/members.store", () => ({ + membersStore: { getState: vi.fn(() => ({ members: mockMembers })) }, +})); + +const mockVoiceState = vi.hoisted(() => ({ + voiceUsers: new Map>(), +})); + +vi.mock("@stores/voice.store", () => ({ + voiceStore: { getState: vi.fn(() => mockVoiceState) }, + setPeerVerification: vi.fn(), + clearPeerVerification: vi.fn(), + clearPeerVerifications: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +// Now import +import { E2EEManager } from "../../src/lib/livekitE2EE"; +import { setPeerVerification } from "@stores/voice.store"; + +const PEER_ID = 42; + +function createManager(ws: { send: ReturnType }): E2EEManager { + return new E2EEManager({ + getWs: () => ws as never, + getServerHost: () => "localhost:7880", + getCurrentChannelId: () => 1, + }); +} + +function sendsOfType(ws: { send: ReturnType }, type: string): unknown[] { + return ws.send.mock.calls.map((c) => c[0]).filter((m: any) => m?.type === type); +} + +describe("E2EEManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMembers.clear(); + mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" }); + }); + + it("setupKeyExchange as key holder generates the room key and sends a signed announce", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + const ok = await mgr.setupKeyExchange(true, 1); + + expect(ok).toBe(true); + expect(mgr.epoch).toBe(1); + expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64"); + const announces = sendsOfType(ws, "voice_e2ee_announce"); + expect(announces).toHaveLength(1); + expect((announces[0] as any).payload.signature).toBe("mock-signature"); + }); + + it("queues an announce before the keypair exists and drains it on setup, sending an offer", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect(mgr.pendingAnnounces).toHaveLength(1); + expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false); + + 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. + 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); + + // 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); + }); + + it("setupKeyExchange as non-key-holder resolves once the key holder's offer arrives", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + // Seed the peer's ECDH key so the offer sender is known. + await mgr.setupKeyExchange(true, 1); + mgr.clearState(); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + ws.send.mockClear(); + + const setupPromise = mgr.setupKeyExchange(false, 1); + // Announce goes out first so the key holder can offer immediately. + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + await mgr.handleOffer(PEER_ID, "enc", "iv"); + + await expect(setupPromise).resolves.toBe(true); + expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64"); + }); + + it("clearState aborts a waiting key exchange so setup fails instead of hanging", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + + const setupPromise = mgr.setupKeyExchange(false, 1); + await vi.waitFor(() => { + expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0); + }); + mgr.clearState(); + + await expect(setupPromise).resolves.toBe(false); + expect(mgr.epoch).toBe(0); + expect(mgr.peerPublicKeys.size).toBe(0); + }); + + it("rotateKeyPeriodically advances the epoch and redistributes the key to peers", async () => { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + ws.send.mockClear(); + + await mgr.rotateKeyPeriodically(); + + expect(mgr.epoch).toBe(2); + const offers = sendsOfType(ws, "voice_e2ee_offer"); + expect(offers).toHaveLength(1); + expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID); + }); +}); diff --git a/Client/tauri-client/tests/unit/logger.test.ts b/Client/tauri-client/tests/unit/logger.test.ts index f5b18370..528f5783 100644 --- a/Client/tauri-client/tests/unit/logger.test.ts +++ b/Client/tauri-client/tests/unit/logger.test.ts @@ -1,7 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createLogger, setLogLevel, + getLogLevel, + applyStoredLogLevel, addLogListener, getLogBuffer, clearLogBuffer, @@ -153,6 +155,19 @@ describe("logger", () => { expect(getLogBuffer().length).toBe(0); }); + it("getLogLevel reflects the current effective level", () => { + setLogLevel("warn"); + expect(getLogLevel()).toBe("warn"); + setLogLevel("error"); + expect(getLogLevel()).toBe("error"); + }); + + it("getLogLevel reflects the applyStoredLogLevel fallback when no pref is stored", () => { + localStorage.clear(); + applyStoredLogLevel("info"); + expect(getLogLevel()).toBe("info"); + }); + it("passes empty string instead of undefined when no data", () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); @@ -163,3 +178,129 @@ describe("logger", () => { expect(infoSpy).toHaveBeenCalledWith(expect.any(String), "no data", ""); }); }); + +describe("applyStoredLogLevel", () => { + beforeEach(() => { + localStorage.clear(); + setLogLevel("debug"); + vi.restoreAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + setLogLevel("debug"); + }); + + it("falls back to the given default when no pref is stored", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + + applyStoredLogLevel("info"); + const log = createLogger("test"); + log.debug("filtered"); + log.info("kept"); + + expect(debugSpy).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledTimes(1); + }); + + it("honors the saved logs_min_level pref over the fallback", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error")); + + applyStoredLogLevel("debug"); + const log = createLogger("test"); + log.warn("filtered"); + log.error("kept"); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it("migrates a legacy unprefixed logs_min_level key and honors it", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Legacy values were stored raw under the unprefixed key. + localStorage.setItem("logs_min_level", "warn"); + + applyStoredLogLevel("debug"); + const log = createLogger("test"); + log.info("filtered"); + log.warn("kept"); + + expect(infoSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + // The legacy value is migrated forward to the prefixed key. + expect(localStorage.getItem("owncord:settings:logs_min_level")).toBe('"warn"'); + }); + + it("ignores invalid stored values and uses the fallback", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("verbose")); + + applyStoredLogLevel("warn"); + const log = createLogger("test"); + log.info("filtered"); + + expect(infoSpy).not.toHaveBeenCalled(); + }); +}); + +describe("log level pref-change live updates", () => { + beforeEach(() => { + localStorage.clear(); + setLogLevel("debug"); + vi.restoreAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + setLogLevel("debug"); + }); + + it("applies a new logs_min_level when owncord:pref-change fires", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error")); + + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { detail: { key: "logs_min_level" } }), + ); + + const log = createLogger("test"); + log.debug("filtered"); + log.error("kept"); + + expect(debugSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it("ignores pref-change events for other keys", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error")); + + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { detail: { key: "compactMode" } }), + ); + + const log = createLogger("test"); + log.debug("kept — level unchanged"); + + expect(debugSpy).toHaveBeenCalledTimes(1); + }); + + it("keeps the current level when the pref is cleared", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + setLogLevel("info"); + + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { detail: { key: "logs_min_level" } }), + ); + + const log = createLogger("test"); + log.info("kept"); + + expect(infoSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Client/tauri-client/tests/unit/logs-tab.test.ts b/Client/tauri-client/tests/unit/logs-tab.test.ts index 0bbf3359..fc842611 100644 --- a/Client/tauri-client/tests/unit/logs-tab.test.ts +++ b/Client/tauri-client/tests/unit/logs-tab.test.ts @@ -6,12 +6,14 @@ const { mockClearLogBuffer, mockAddLogListener, mockSetLogLevel, + mockGetLogLevel, // eslint-disable-next-line @typescript-eslint/no-explicit-any } = vi.hoisted(() => ({ mockGetLogBuffer: vi.fn(), mockClearLogBuffer: vi.fn(), mockAddLogListener: vi.fn(), mockSetLogLevel: vi.fn(), + mockGetLogLevel: vi.fn(), })); vi.mock("@lib/logger", () => ({ @@ -19,6 +21,7 @@ vi.mock("@lib/logger", () => ({ clearLogBuffer: mockClearLogBuffer, addLogListener: mockAddLogListener, setLogLevel: mockSetLogLevel, + getLogLevel: mockGetLogLevel, createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); @@ -46,6 +49,7 @@ describe("LogsTab", () => { controller = new AbortController(); mockGetLogBuffer.mockReturnValue([]); mockAddLogListener.mockReturnValue(() => {}); + mockGetLogLevel.mockReturnValue("info"); }); afterEach(() => { @@ -248,6 +252,20 @@ describe("LogsTab", () => { expect(localStorage.getItem("owncord:settings:logs_filter_level")).toBe('"warn"'); }); + it("defaults min-level select to the effective runtime level when no pref is saved", () => { + mockGetLogBuffer.mockReturnValue([]); + localStorage.clear(); + mockGetLogLevel.mockReturnValue("info"); + + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const levelSelect = el.querySelectorAll("select")[1]!; + + // Reflects getLogLevel() rather than the first option (DEBUG); no save/apply. + expect(levelSelect.value).toBe("info"); + expect(mockSetLogLevel).not.toHaveBeenCalled(); + }); + it("restores legacy unprefixed min level and migrates it", () => { mockGetLogBuffer.mockReturnValue([]); localStorage.clear(); diff --git a/Client/tauri-client/tests/unit/media.test.ts b/Client/tauri-client/tests/unit/media.test.ts index 4582e6de..025a9dae 100644 --- a/Client/tauri-client/tests/unit/media.test.ts +++ b/Client/tauri-client/tests/unit/media.test.ts @@ -93,6 +93,18 @@ function oembedFail() { }; } +/** + * media.ts caches showEmbeds/inlineMedia/showLinkPreviews/animateGifs at + * module level and re-reads them on "owncord:pref-change" (the event savePref + * dispatches). After changing loadPrefMock, dispatch those events so the + * module's cached values pick up the new mock implementation. + */ +function syncPrefCache(): void { + for (const key of ["showEmbeds", "inlineMedia", "showLinkPreviews", "animateGifs"]) { + window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } })); + } +} + /** Simulate image load event on the first found inside an element. */ function fireImgLoad(parent: HTMLElement): void { const img = parent.querySelector("img") as HTMLImageElement | null; @@ -130,6 +142,7 @@ describe("media.ts", () => { observeMediaMock.mockReset(); loadPrefMock.mockReset(); loadPrefMock.mockImplementation((_key: string, fallback: unknown) => fallback); + syncPrefCache(); clearMediaCaches(); document.body.innerHTML = ""; }); @@ -310,6 +323,7 @@ describe("media.ts", () => { if (key === "animateGifs") return true; return fallback; }); + syncPrefCache(); const url = "https://example.com/animated.gif"; const wrap = renderInlineImage(url); @@ -327,6 +341,7 @@ describe("media.ts", () => { if (key === "animateGifs") return false; return fallback; }); + syncPrefCache(); const url = "https://example.com/frozen.gif"; const wrap = renderInlineImage(url); @@ -1263,6 +1278,7 @@ describe("media.ts", () => { if (key === "showEmbeds") return false; return true; }); + syncPrefCache(); const fragment = renderUrlEmbeds("https://www.youtube.com/watch?v=skip1"); @@ -1276,6 +1292,7 @@ describe("media.ts", () => { if (key === "inlineMedia") return false; return true; }); + syncPrefCache(); const fragment = renderUrlEmbeds("https://example.com/photo.png"); @@ -1289,6 +1306,7 @@ describe("media.ts", () => { if (key === "showLinkPreviews") return false; return true; }); + syncPrefCache(); const fragment = renderUrlEmbeds("https://example.com/article"); @@ -1299,6 +1317,7 @@ describe("media.ts", () => { it("produces empty fragment when all preferences are disabled", () => { loadPrefMock.mockReturnValue(false); + syncPrefCache(); const fragment = renderUrlEmbeds( "https://www.youtube.com/watch?v=abc https://example.com/pic.png https://example.com/page", diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts index 248054c5..969fc912 100644 --- a/Client/tauri-client/tests/unit/member-list.test.ts +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createMemberList } from "@components/MemberList"; import type { MemberListOptions } from "@components/MemberList"; -import { membersStore } from "@stores/members.store"; +import { membersStore, updatePresence, updateMemberRole } from "@stores/members.store"; import type { Member } from "@stores/members.store"; import { authStore } from "@stores/auth.store"; +import { channelsStore, setRoles } from "@stores/channels.store"; import type { UserStatus } from "../../src/lib/types"; function resetStore(): void { @@ -220,6 +221,38 @@ describe("MemberList", () => { expect(bobDot.style.background).toBe("var(--red)"); }); + it("offers the server's own roles in the Change Role submenu", () => { + // A hardcoded list left custom roles unassignable — and unresolvable to a + // role id, so choosing one silently did nothing. + setRoles([ + { id: 1, name: "Owner", color: null, permissions: 0 }, + { id: 2, name: "Staff", color: null, permissions: 0 }, + { id: 3, name: "VIP", color: null, permissions: 0 }, + ]); + authStore.setState(() => ({ + token: "tok", + user: { id: 99, username: "Admin", avatar: null, role: "admin" }, + serverName: "Test", + motd: null, + isAuthenticated: true, + })); + setTestMembers(testMembers); + memberList.mount(container); + + const memberItem = container.querySelector('[data-testid="member-3"]') as HTMLDivElement; + memberItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + + const submenu = document.body.querySelector(".context-menu__submenu"); + expect(submenu).not.toBeNull(); + const roleLabels = Array.from(submenu!.querySelectorAll(".context-menu__item")).map( + (i) => i.textContent, + ); + // "owner" is not a context-menu action. + expect(roleLabels).toEqual(["staff", "vip"]); + + document.body.querySelector(".context-menu")?.remove(); + }); + it("context menu does not appear for non-admin/non-owner roles", () => { setTestMembers(testMembers); const opts: MemberListOptions = { @@ -309,6 +342,68 @@ describe("MemberList", () => { expect(names).toEqual(["Online", "Idle", "Dnd", "Offline"]); }); + it("patches a presence-only change in place, keeping row identity", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const eveRowBefore = container.querySelector('[data-testid="member-5"]') as HTMLDivElement; + expect(eveRowBefore).not.toBeNull(); + const allRowsBefore = Array.from(container.querySelectorAll(".member-item")); + + // Presence-only update (same username/role/avatar) — via the real action. + updatePresence(5, "dnd"); + membersStore.flush(); + + // Same DOM element — no rebuild, status dot patched in place. + const eveRowAfter = container.querySelector('[data-testid="member-5"]'); + expect(eveRowAfter).toBe(eveRowBefore); + const dot = eveRowAfter!.querySelector(".mi-status") as HTMLDivElement; + expect(dot.style.background).toBe("var(--red)"); + expect(dot.getAttribute("aria-label")).toBe("dnd"); + expect(dot.title).toBe("dnd"); + + // Every other row also kept its identity. + const allRowsAfter = Array.from(container.querySelectorAll(".member-item")); + expect(allRowsAfter).toEqual(allRowsBefore); + }); + + it("toggles the offline class in place when presence flips to/from offline", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const eveRow = container.querySelector('[data-testid="member-5"]') as HTMLDivElement; + expect(eveRow.classList.contains("offline")).toBe(false); + + updatePresence(5, "offline"); + membersStore.flush(); + expect(container.querySelector('[data-testid="member-5"]')).toBe(eveRow); + expect(eveRow.classList.contains("offline")).toBe(true); + + updatePresence(5, "online"); + membersStore.flush(); + expect(container.querySelector('[data-testid="member-5"]')).toBe(eveRow); + expect(eveRow.classList.contains("offline")).toBe(false); + }); + + it("still fully rebuilds when a member's role changes", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const eveRowBefore = container.querySelector('[data-testid="member-5"]'); + + updateMemberRole(5, "admin"); + membersStore.flush(); + + // Structural change → rebuild: new row element, Eve now in the ADMIN group. + const eveRowAfter = container.querySelector('[data-testid="member-5"]'); + expect(eveRowAfter).not.toBeNull(); + expect(eveRowAfter).not.toBe(eveRowBefore); + const headerTexts = Array.from(container.querySelectorAll(".member-role-group")).map( + (h) => h.textContent, + ); + expect(headerTexts.find((t) => t?.includes("ADMIN"))).toContain("3"); + }); + it("re-renders when store updates to a different member set", () => { setTestMembers(testMembers); memberList.mount(container); diff --git a/Client/tauri-client/tests/unit/message-list-media-release.test.ts b/Client/tauri-client/tests/unit/message-list-media-release.test.ts new file mode 100644 index 00000000..7057993e --- /dev/null +++ b/Client/tauri-client/tests/unit/message-list-media-release.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// jsdom does not provide ResizeObserver — stub it so MessageList can mount. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } as unknown as typeof ResizeObserver; +} + +// Spy on the media-visibility manager: MessageList must release every tracked +// (unobserveMedia) before discarding rendered rows, otherwise the +// IntersectionObserver + allTracked set + pending timers retain every GIF +// ever rendered. +const { observeMediaMock, unobserveMediaMock } = vi.hoisted(() => ({ + observeMediaMock: vi.fn(), + unobserveMediaMock: vi.fn(), +})); +vi.mock("@lib/media-visibility", () => ({ + observeMedia: observeMediaMock, + unobserveMedia: unobserveMediaMock, +})); + +import { createMessageList } from "@components/MessageList"; +import type { MessageListOptions } from "@components/MessageList"; +import { messagesStore } from "@stores/messages.store"; +import { membersStore } from "@stores/members.store"; +import type { Message } from "@stores/messages.store"; + +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + historyLoadState: new Map(), + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMessage(overrides: Partial & { id: number }): Message { + return { + channelId: 1, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${overrides.id}`, + replyTo: null, + attachments: [], + reactions: [], + pinned: false, + editedAt: null, + deleted: false, + timestamp: "2024-01-15T12:00:00Z", + status: "sent", + correlationId: null, + errorCode: null, + ...overrides, + }; +} + +function setMessages(channelId: number, messages: Message[]): void { + messagesStore.setState((prev) => { + const next = new Map(prev.messagesByChannel); + next.set(channelId, messages); + return { ...prev, messagesByChannel: next }; + }); +} + +describe("MessageList media release (GIF observer leak fix)", () => { + let container: HTMLDivElement; + let msgList: ReturnType; + let options: MessageListOptions; + + beforeEach(() => { + resetStores(); + observeMediaMock.mockClear(); + unobserveMediaMock.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + options = { + channelId: 1, + channelName: "general", + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + }; + msgList = createMessageList(options); + }); + + afterEach(() => { + msgList.destroy?.(); + container.remove(); + }); + + it("unobserves rendered elements before a full re-render discards them", () => { + setMessages(1, [makeMessage({ id: 2, content: "look https://example.com/anim.gif" })]); + msgList.mount(container); + + const img = container.querySelector(".virtual-content img"); + expect(img).not.toBeNull(); + unobserveMediaMock.mockClear(); + + // Prepend an older message — NOT a suffix extension, so the list takes + // the full-rebuild path that tears the rendered rows down. + setMessages(1, [ + makeMessage({ id: 1, content: "older", timestamp: "2024-01-15T11:00:00Z" }), + makeMessage({ id: 2, content: "look https://example.com/anim.gif" }), + ]); + messagesStore.flush(); + + expect(unobserveMediaMock).toHaveBeenCalledWith(img); + }); + + it("unobserves rendered elements on destroy", () => { + setMessages(1, [makeMessage({ id: 1, content: "look https://example.com/anim.gif" })]); + msgList.mount(container); + + const img = container.querySelector(".virtual-content img"); + expect(img).not.toBeNull(); + unobserveMediaMock.mockClear(); + + msgList.destroy?.(); + + expect(unobserveMediaMock).toHaveBeenCalledWith(img); + }); + + it("does not unobserve retained rows on the incremental append fast path", () => { + const gifMessage = makeMessage({ id: 1, content: "look https://example.com/anim.gif" }); + setMessages(1, [gifMessage]); + msgList.mount(container); + expect(container.querySelector(".virtual-content img")).not.toBeNull(); + unobserveMediaMock.mockClear(); + + // Suffix extension (same leading references) → rows are kept, so nothing + // must be released. + setMessages(1, [ + gifMessage, + makeMessage({ id: 2, content: "plain follow-up", timestamp: "2024-01-15T12:01:00Z" }), + ]); + messagesStore.flush(); + + expect(unobserveMediaMock).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 8d321406..18e07031 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -374,4 +374,116 @@ describe("MessageList", () => { expect(() => msgList.destroy?.()).not.toThrow(); expect(container.querySelector(".messages-container")).toBeNull(); }); + + it("does not re-render when a DIFFERENT channel's messages update", () => { + setMessages(1, [makeMessage({ id: 1, content: "Mine" })]); + msgList.mount(container); + + const rowBefore = container.querySelector("[data-testid='message-1']"); + expect(rowBefore).not.toBeNull(); + + // Update another channel — this list (channel 1) must not rebuild. + setMessages(2, [makeMessage({ id: 50, channelId: 2, content: "Other channel" })]); + messagesStore.flush(); + + const rowAfter = container.querySelector("[data-testid='message-1']"); + expect(rowAfter).toBe(rowBefore); // same element instance — no re-render + }); + + describe("incremental tail append", () => { + it("appends new rows without rebuilding existing ones", () => { + setMessages(1, [ + makeMessage({ id: 1, content: "First" }), + makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }), + ]); + msgList.mount(container); + + const row1Before = container.querySelector("[data-testid='message-1']"); + const row2Before = container.querySelector("[data-testid='message-2']"); + expect(row1Before).not.toBeNull(); + expect(row2Before).not.toBeNull(); + + // Pure suffix extension → fast path: existing rows keep their identity. + setMessages(1, [ + ...(messagesStore.getState().messagesByChannel.get(1) ?? []), + makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }), + ]); + messagesStore.flush(); + + expect(container.querySelector("[data-testid='message-1']")).toBe(row1Before); + expect(container.querySelector("[data-testid='message-2']")).toBe(row2Before); + expect(container.querySelector("[data-testid='message-3']")).not.toBeNull(); + }); + + it("appended rows preserve order, grouping, and day dividers vs a full rebuild", () => { + const initial = [ + makeMessage({ id: 1, content: "First", timestamp: "2024-01-15T12:00:00Z" }), + makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }), + ]; + setMessages(1, initial); + msgList.mount(container); + + const appended = [ + // Same user within threshold → must render grouped. + makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }), + // Next day, different user → must be preceded by a day divider. + makeMessage({ + id: 4, + content: "Fourth", + user: { id: 2, username: "Bob", avatar: null }, + timestamp: "2024-01-16T09:00:00Z", + }), + ]; + const finalMessages = [...initial, ...appended]; + setMessages(1, finalMessages); + messagesStore.flush(); + + const content = container.querySelector(".virtual-content")!; + + // Reference render: a fresh list mounted with the final message set + // (full rebuild path) must produce the same structure. + const refContainer = document.createElement("div"); + document.body.appendChild(refContainer); + const refList = createMessageList(options); + refList.mount(refContainer); + const refContent = refContainer.querySelector(".virtual-content")!; + + const describeChildren = (el: Element): string[] => + Array.from(el.children).map((c) => `${c.className}|${c.getAttribute("data-testid") ?? ""}`); + expect(describeChildren(content)).toEqual(describeChildren(refContent)); + + // Explicit semantic checks on the appended tail. + expect(container.querySelectorAll(".msg-day-divider").length).toBe(2); + const row3 = container.querySelector("[data-testid='message-3']")!; + expect(row3.classList.contains("grouped")).toBe(true); + const row4 = container.querySelector("[data-testid='message-4']")!; + expect(row4.classList.contains("grouped")).toBe(false); + const ids = Array.from(content.querySelectorAll("[data-testid^='message-']")).map((el) => + el.getAttribute("data-testid"), + ); + expect(ids).toEqual(["message-1", "message-2", "message-3", "message-4"]); + + refList.destroy?.(); + refContainer.remove(); + }); + + it("falls back to a full rebuild for non-append updates (edit)", () => { + setMessages(1, [ + makeMessage({ id: 1, content: "Original" }), + makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }), + ]); + msgList.mount(container); + + // Replace message 1's object (an edit) — not a suffix extension. + setMessages(1, [ + makeMessage({ id: 1, content: "Edited" }), + makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }), + ]); + messagesStore.flush(); + + const row1 = container.querySelector("[data-testid='message-1']"); + expect(row1).not.toBeNull(); + expect(row1!.textContent).toContain("Edited"); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/navigation-guard.test.ts b/Client/tauri-client/tests/unit/navigation-guard.test.ts new file mode 100644 index 00000000..81412aae --- /dev/null +++ b/Client/tauri-client/tests/unit/navigation-guard.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { createNavigationGuard } from "../../src/lib/navigation-guard"; + +describe("createNavigationGuard", () => { + it("reports the only navigation as current", () => { + const guard = createNavigationGuard(); + const isCurrent = guard.begin(); + expect(isCurrent()).toBe(true); + }); + + it("supersedes an earlier navigation when a newer one begins", () => { + const guard = createNavigationGuard(); + const first = guard.begin(); + const second = guard.begin(); + + expect(first()).toBe(false); + expect(second()).toBe(true); + }); + + it("only the latest of many navigations is current", () => { + const guard = createNavigationGuard(); + const predicates = [guard.begin(), guard.begin(), guard.begin()]; + + expect(predicates.map((p) => p())).toEqual([false, false, true]); + }); + + it("discards a stale async mount: a navigation that awaited across a newer begin() is superseded", async () => { + const guard = createNavigationGuard(); + const mounted: string[] = []; + + // Simulates renderPage: destroy happens synchronously, mount only after an + // awaited dynamic import — and only if still the current navigation. + async function renderPage(pageId: string, importDelay: Promise): Promise { + const isCurrent = guard.begin(); + await importDelay; // dynamic import boundary + if (!isCurrent()) return; + mounted.push(pageId); + } + + let resolveSlow!: () => void; + const slowImport = new Promise((resolve) => { + resolveSlow = resolve; + }); + + const slowRender = renderPage("main", slowImport); + // A newer navigation begins (and mounts) while the first import is pending. + await renderPage("connect", Promise.resolve()); + + resolveSlow(); + await slowRender; + + expect(mounted).toEqual(["connect"]); + }); + + it("independent guards do not interfere", () => { + const a = createNavigationGuard(); + const b = createNavigationGuard(); + const aFirst = a.begin(); + b.begin(); + + expect(aFirst()).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts index e4084d23..5dd78391 100644 --- a/Client/tauri-client/tests/unit/notifications.test.ts +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -9,13 +9,11 @@ const { testPrefs } = vi.hoisted(() => ({ testPrefs: new Map(), })); -// Mock the settings helpers -vi.mock("../../src/components/settings/helpers", () => ({ +// Mock the preference store shared by the settings panel and lib modules +vi.mock("../../src/lib/preferences", () => ({ STORAGE_PREFIX: "owncord:settings:", loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback, savePref: (key: string, value: unknown) => testPrefs.set(key, value), - THEMES: { dark: {}, midnight: {}, light: {} }, - applyTheme: vi.fn(), })); // Mock livekitSession (imported transitively by auth.store) @@ -125,6 +123,7 @@ describe("notifyIncomingMessage", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }, ], ]), @@ -934,6 +933,45 @@ describe("notifyIncomingMessage", () => { }); }); + describe("Do Not Disturb", () => { + it("suppresses the desktop notification and the sound while DND", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + (sendNotification as ReturnType).mockClear(); + mockOscillator.start.mockClear(); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("notificationSounds", true); + testPrefs.set("flashTaskbar", true); + testPrefs.set("userStatus", "dnd"); + + notifyIncomingMessage(makePayload()); + + // The taskbar flash still fires — it's the one passive cue DND keeps. + await vi.waitFor(() => { + const win = getCurrentWindow(); + expect(win.requestUserAttention).toHaveBeenCalled(); + }); + + expect(sendNotification).not.toHaveBeenCalled(); + expect(mockOscillator.start).not.toHaveBeenCalled(); + }); + + it("still notifies for other statuses", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("userStatus", "idle"); + + notifyIncomingMessage(makePayload()); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); + }); + }); + describe("playNotificationSound: oscillator params", () => { it("sets frequency to 800 then 600", () => { mockOscillator.frequency.setValueAtTime.mockClear(); diff --git a/Client/tauri-client/tests/unit/ptt.test.ts b/Client/tauri-client/tests/unit/ptt.test.ts index bf29c825..d1df5353 100644 --- a/Client/tauri-client/tests/unit/ptt.test.ts +++ b/Client/tauri-client/tests/unit/ptt.test.ts @@ -485,7 +485,10 @@ describe("ptt-state event listener", () => { expect(capturedCallback).not.toBeNull(); capturedCallback!({ payload: true }); // key pressed - expect(mockSetMuted).toHaveBeenCalledWith(false); + // setMuted is reached via a dynamic import of livekitSession + await vi.waitFor(() => { + expect(mockSetMuted).toHaveBeenCalledWith(false); + }); }); it("calls setMuted(true) when PTT is released (payload false) and in a voice channel", async () => { @@ -506,7 +509,10 @@ describe("ptt-state event listener", () => { capturedCallback!({ payload: false }); // key released - expect(mockSetMuted).toHaveBeenCalledWith(true); + // setMuted is reached via a dynamic import of livekitSession + await vi.waitFor(() => { + expect(mockSetMuted).toHaveBeenCalledWith(true); + }); }); it("does not call setMuted when not in a voice channel", async () => { @@ -527,6 +533,9 @@ describe("ptt-state event listener", () => { capturedCallback!({ payload: true }); + // Flush pending microtasks so a (wrong) dynamic-import path would have + // had the chance to call setMuted before we assert it never happens. + await new Promise((r) => setTimeout(r, 0)); expect(mockSetMuted).not.toHaveBeenCalled(); }); }); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index a1314792..6152aaf0 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -18,6 +18,8 @@ import { } from "../../src/components/message-list/renderers"; import type { Message } from "../../src/stores/messages.store"; import { membersStore } from "../../src/stores/members.store"; +import { channelsStore, setRoles } from "../../src/stores/channels.store"; +import { authStore } from "../../src/stores/auth.store"; import type { MessageListOptions } from "../../src/components/MessageList"; function resetStores(): void { @@ -25,6 +27,14 @@ function resetStores(): void { members: new Map(), typingUsers: new Map(), })); + channelsStore.setState((prev) => ({ ...prev, roles: [] })); + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); } function makeMessage(overrides: Partial = {}): Message { @@ -1033,6 +1043,49 @@ describe("renderers", () => { ac.abort(); }); + + it("offers delete on others' messages to a role with MANAGE_MESSAGES", () => { + // MANAGE_MESSAGES = 0x10000 (see lib/types Permission). + setRoles([{ id: 2, name: "moderator", color: null, permissions: 0x10000 }]); + authStore.setState(() => ({ + token: "tok", + user: { id: 999, username: "Mod", avatar: null, role: "moderator" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + + const opts = makeOpts({ currentUserId: 999 }); + const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } }); + const ac = new AbortController(); + container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal)); + + expect(container.querySelector("[data-testid='msg-delete-1']")).not.toBeNull(); + // Editing someone else's message is still not a thing. + expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull(); + + ac.abort(); + }); + + it("withholds delete from a role without MANAGE_MESSAGES", () => { + setRoles([{ id: 3, name: "member", color: null, permissions: 0 }]); + authStore.setState(() => ({ + token: "tok", + user: { id: 999, username: "Nobody", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + + const opts = makeOpts({ currentUserId: 999 }); + const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } }); + const ac = new AbortController(); + container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal)); + + expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull(); + + ac.abort(); + }); }); // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/screen-share-button.test.ts b/Client/tauri-client/tests/unit/screen-share-button.test.ts index a0661fc0..fdda83aa 100644 --- a/Client/tauri-client/tests/unit/screen-share-button.test.ts +++ b/Client/tauri-client/tests/unit/screen-share-button.test.ts @@ -79,6 +79,7 @@ function setVoiceConnected(screenshare = false): void { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }, ], ]), diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index b0868785..3d4fc109 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -41,6 +41,7 @@ vi.mock("@stores/auth.store", () => ({ getState: () => ({ user: { id: 1, username: "testuser", totp_enabled: false }, }), + subscribeSelector: vi.fn(() => () => {}), }, updateUser: vi.fn(), })); @@ -375,6 +376,63 @@ describe("SettingsOverlay", () => { overlay.destroy?.(); }); + it("requires the current password before calling the server", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const inputs = container.querySelectorAll("input[type='password']"); + (inputs[0] as HTMLInputElement).value = ""; + (inputs[1] as HTMLInputElement).value = "newpassword123"; + (inputs[2] as HTMLInputElement).value = "newpassword123"; + + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLElement; + changePwBtn.click(); + + // An empty current password is a guaranteed 403 — and each one counts + // against the server's lockout counter. + expect(defaultOptions.onChangePassword).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Enter your current password."); + + overlay.destroy?.(); + }); + + it("blocks a double submit while the password change is in flight", async () => { + let resolveChange: (() => void) | null = null; + const onChangePassword = vi.fn( + () => + new Promise((resolve) => { + resolveChange = resolve; + }), + ); + const overlay = createSettingsOverlay({ ...defaultOptions, onChangePassword }); + overlay.mount(container); + + const inputs = container.querySelectorAll("input[type='password']"); + (inputs[0] as HTMLInputElement).value = "oldpass123"; + (inputs[1] as HTMLInputElement).value = "newpassword123"; + (inputs[2] as HTMLInputElement).value = "newpassword123"; + + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLButtonElement; + changePwBtn.click(); + expect(changePwBtn.disabled).toBe(true); + expect(changePwBtn.textContent).toBe("Changing..."); + + changePwBtn.click(); + expect(onChangePassword).toHaveBeenCalledTimes(1); + + resolveChange!(); + await vi.waitFor(() => { + expect(changePwBtn.disabled).toBe(false); + expect(changePwBtn.textContent).toBe("Change Password"); + }); + + overlay.destroy?.(); + }); + it("calls onChangePassword and clears inputs on success", async () => { const onChangePassword = vi.fn().mockResolvedValue(undefined); const overlay = createSettingsOverlay({ ...defaultOptions, onChangePassword }); @@ -852,6 +910,49 @@ describe("SettingsOverlay", () => { overlay.destroy?.(); }); + // --- Reopen rebuilds live content --- + + it("rebuilds the active tab when the panel is reopened", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + overlay.open(); + const firstPane = container.querySelector(".settings-content .settings-pane"); + expect(firstPane).not.toBeNull(); + + // Closing tears down the live parts of the tab (mic meter, camera preview, + // log listener) — reopening must build a fresh pane, not show the corpse. + overlay.close(); + overlay.open(); + + const secondPane = container.querySelector(".settings-content .settings-pane"); + expect(secondPane).not.toBeNull(); + expect(secondPane).not.toBe(firstPane); + // Exactly one pane — the old one was replaced, not appended to. + expect(container.querySelectorAll(".settings-content .settings-pane").length).toBe(1); + }); + + it("re-reads preferences when reopened", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + overlay.open(); + const appearanceTab = Array.from( + container.querySelectorAll(".settings-sidebar > button.settings-nav-item"), + ).find((b) => b.textContent === "Appearance") as HTMLElement; + appearanceTab.click(); + + let slider = container.querySelector(".settings-slider") as HTMLInputElement; + expect(slider.value).toBe("16"); + + overlay.close(); + localStorage.setItem("owncord:settings:fontSize", JSON.stringify(20)); + overlay.open(); + + slider = container.querySelector(".settings-slider") as HTMLInputElement; + expect(slider.value).toBe("20"); + }); + // --- Cleanup --- it("destroy removes root from DOM", () => { diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index c78275f5..7bf738ba 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -1033,6 +1033,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -1062,6 +1063,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 50 }; }); @@ -1284,6 +1286,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -1321,6 +1324,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); next.set(2, { id: 2, @@ -1331,6 +1335,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next }; }); @@ -1393,6 +1398,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 100 }; }); @@ -1797,6 +1803,7 @@ describe("SidebarArea", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next }; }); @@ -1839,7 +1846,7 @@ describe("SidebarArea", () => { /** Extract callbacks passed to createMemberList */ function getMemberListCallbacks(): { onKick: (userId: number, username: string) => Promise; - onBan: (userId: number, username: string) => Promise; + onBan: (userId: number, username: string, reason: string) => Promise; onChangeRole: (userId: number, username: string, newRole: string) => Promise; } { const calls = (createMemberList as MockedFn).mock.calls; @@ -1906,9 +1913,9 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", "spamming"); - expect(opts.api.adminBanMember).toHaveBeenCalledWith(3); + expect(opts.api.adminBanMember).toHaveBeenCalledWith(3, "spamming"); expect(mockShow).toHaveBeenCalledWith("Banned Bob", "success"); cleanup(result); @@ -1924,7 +1931,7 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", ""); expect(mockShow).toHaveBeenCalledWith("Ban denied", "error"); @@ -1941,7 +1948,7 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", ""); expect(mockShow).toHaveBeenCalledWith("Failed to ban member", "error"); @@ -2019,7 +2026,7 @@ describe("SidebarArea", () => { cleanup(result); }); - it("onChangeRole does nothing when role name not found", async () => { + it("onChangeRole reports an unresolvable role instead of failing silently", async () => { const mockShow = vi.fn(); const opts = defaultOpts(); (opts.getToast as MockedFn).mockReturnValue({ show: mockShow }); @@ -2031,7 +2038,10 @@ describe("SidebarArea", () => { await callbacks.onChangeRole(4, "Charlie", "nonexistent"); expect(opts.api.adminChangeRole).not.toHaveBeenCalled(); - expect(mockShow).not.toHaveBeenCalled(); + expect(mockShow).toHaveBeenCalledWith( + 'Unknown role "nonexistent" — try reconnecting', + "error", + ); cleanup(result); }); diff --git a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts index ad469d5a..764fc6aa 100644 --- a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts @@ -119,6 +119,7 @@ describe("SidebarDmHelpers", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next }; }); @@ -144,6 +145,7 @@ describe("SidebarDmHelpers", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next }; }); @@ -175,6 +177,7 @@ describe("SidebarDmHelpers", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -199,6 +202,7 @@ describe("SidebarDmHelpers", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels: next, activeChannelId: 50 }; }); diff --git a/Client/tauri-client/tests/unit/sidebar-member-section.test.ts b/Client/tauri-client/tests/unit/sidebar-member-section.test.ts index 9636a89b..0b53aa90 100644 --- a/Client/tauri-client/tests/unit/sidebar-member-section.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-member-section.test.ts @@ -391,7 +391,7 @@ describe("SidebarMemberSection", () => { /** Extract the callbacks passed to createMemberList */ function getCapturedCallbacks(): { onKick: (userId: number, username: string) => Promise; - onBan: (userId: number, username: string) => Promise; + onBan: (userId: number, username: string, reason: string) => Promise; onChangeRole: (userId: number, username: string, newRole: string) => Promise; } { const calls = (createMemberList as ReturnType).mock.calls; @@ -484,9 +484,9 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", "spamming"); - expect(mockApi.adminBanMember).toHaveBeenCalledWith(3); + expect(mockApi.adminBanMember).toHaveBeenCalledWith(3, "spamming"); expect(mockShow).toHaveBeenCalledWith("Banned Bob", "success"); section.destroy(); @@ -508,7 +508,7 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", ""); expect(mockShow).toHaveBeenCalledWith("Ban denied", "error"); @@ -531,7 +531,7 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob"); + await callbacks.onBan(3, "Bob", ""); expect(mockShow).toHaveBeenCalledWith("Failed to ban member", "error"); @@ -608,7 +608,7 @@ describe("SidebarMemberSection", () => { section.destroy(); }); - it("changeRole: does nothing when role name is not found", async () => { + it("changeRole: reports an unresolvable role instead of failing silently", async () => { const mockShow = vi.fn(); const mockApi = { adminKickMember: vi.fn(), @@ -626,9 +626,12 @@ describe("SidebarMemberSection", () => { const callbacks = getCapturedCallbacks(); await callbacks.onChangeRole(4, "Charlie", "nonexistent-role"); - // Should not call API or show toast because roleId is undefined + // No API call — but the user is told, rather than seeing a dead menu item. expect(mockApi.adminChangeRole).not.toHaveBeenCalled(); - expect(mockShow).not.toHaveBeenCalled(); + expect(mockShow).toHaveBeenCalledWith( + 'Unknown role "nonexistent-role" — try reconnecting', + "error", + ); section.destroy(); }); diff --git a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts index 86b38e42..e0cd8ee9 100644 --- a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts +++ b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts @@ -3,6 +3,7 @@ import { authStore } from "@stores/auth.store"; import { uiStore, setConnectionStatus } from "@stores/ui.store"; import { createUserBar } from "@components/UserBar"; +import { loadUserStatus, saveUserStatus } from "@lib/userStatus"; import type { WsClient } from "@lib/ws"; function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void { @@ -49,6 +50,7 @@ describe("StatusPicker wired to UserBar", () => { container = document.createElement("div"); document.body.appendChild(container); vi.clearAllMocks(); + localStorage.clear(); // The picker gates on the store-backed connection status (UX spec §3). setConnectionStatus("connected"); }); @@ -130,6 +132,49 @@ describe("StatusPicker wired to UserBar", () => { expect(wrap.title).toBe("Offline"); }); + it("starts from the stored status instead of always 'online'", () => { + setAuthState({ username: "alice" }, true); + saveUserStatus("dnd"); + const ws = createMockWs("connected"); + comp = createUserBar({ ws }); + comp.mount(container); + + const dot = container.querySelector(".status-picker-dot") as HTMLElement; + dot.click(); + const checks = container.querySelectorAll(".status-picker-option-check"); + // Third option is "Do Not Disturb" — only its checkmark is visible. + expect((checks[2] as HTMLElement).style.display).toBe(""); + expect((checks[0] as HTMLElement).style.display).toBe("none"); + }); + + it("persists the selected status so the settings panel agrees", () => { + setAuthState({ username: "alice" }, true); + const ws = createMockWs("connected"); + comp = createUserBar({ ws }); + comp.mount(container); + + (container.querySelector(".status-picker-dot") as HTMLElement).click(); + const options = container.querySelectorAll(".status-picker-option"); + (options[1] as HTMLElement).click(); // "Idle" + + expect(loadUserStatus()).toBe("idle"); + }); + + it("follows a status change made elsewhere (settings Account tab)", () => { + setAuthState({ username: "alice" }, true); + const ws = createMockWs("connected"); + comp = createUserBar({ ws }); + comp.mount(container); + + const dot = container.querySelector(".status-picker-dot") as HTMLElement; + dot.click(); + + saveUserStatus("dnd"); + + const checks = container.querySelectorAll(".status-picker-option-check"); + expect((checks[2] as HTMLElement).style.display).toBe(""); + }); + it("status picker is disabled without a ws send path even when connected", () => { setAuthState({ username: "alice" }, true); comp = createUserBar({}); diff --git a/Client/tauri-client/tests/unit/stored-appearance.test.ts b/Client/tauri-client/tests/unit/stored-appearance.test.ts index a2417a33..90cf183d 100644 --- a/Client/tauri-client/tests/unit/stored-appearance.test.ts +++ b/Client/tauri-client/tests/unit/stored-appearance.test.ts @@ -12,66 +12,7 @@ vi.mock("@lib/themes", () => ({ applyThemeByName: mockApplyThemeByName, })); -vi.mock("@stores/ui.store", () => ({ - uiStore: { - getState: () => ({ settingsOpen: false }), - subscribe: () => () => {}, - }, -})); - -vi.mock("@stores/auth.store", () => ({ - authStore: { - getState: () => ({ user: null }), - }, -})); - -vi.mock("@lib/icons", () => ({ - createIcon: () => document.createElement("span"), -})); - -vi.mock("@components/settings/AccountTab", () => ({ - buildAccountTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/AppearanceTab", () => ({ - buildAppearanceTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/NotificationsTab", () => ({ - buildNotificationsTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/TextImagesTab", () => ({ - buildTextImagesTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/AccessibilityTab", () => ({ - buildAccessibilityTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/VoiceAudioTab", () => ({ - createVoiceAudioTab: () => ({ - build: () => document.createElement("div"), - cleanup: () => {}, - }), -})); - -vi.mock("@components/settings/KeybindsTab", () => ({ - buildKeybindsTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/AdvancedTab", () => ({ - buildAdvancedTab: () => document.createElement("div"), -})); - -vi.mock("@components/settings/LogsTab", () => ({ - createLogsTab: () => ({ - build: () => document.createElement("div"), - cleanup: () => {}, - }), -})); - -import { applyStoredAppearance } from "@components/SettingsOverlay"; +import { applyStoredAppearance } from "@lib/appearance"; describe("applyStoredAppearance", () => { beforeEach(() => { diff --git a/Client/tauri-client/tests/unit/totp-settings.test.ts b/Client/tauri-client/tests/unit/totp-settings.test.ts index 5319cbdf..d1ae84ef 100644 --- a/Client/tauri-client/tests/unit/totp-settings.test.ts +++ b/Client/tauri-client/tests/unit/totp-settings.test.ts @@ -46,6 +46,7 @@ vi.mock("@stores/auth.store", () => ({ getState: () => ({ user: { id: 1, username: "testuser", totp_enabled: mockTotpEnabled }, }), + subscribeSelector: vi.fn(() => () => {}), }, updateUser: vi.fn((patch: Record) => { if ("totp_enabled" in patch) { @@ -236,6 +237,46 @@ describe("TOTP Settings", () => { overlay.destroy?.(); }); + it("warns the codes are shown once and offers a copy button", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + mockTotpEnabled = false; + const options = makeOptions(); + const overlay = createSettingsOverlay(options); + overlay.mount(container); + + (container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement).click(); + (container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement).value = + "mypassword123"; + ( + Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement + ).click(); + + await vi.waitFor(() => { + expect(container.querySelector("[data-testid='totp-copy-backup-codes']")).not.toBeNull(); + }); + + expect(container.textContent).toContain("you won't see them again"); + + const copyBtn = container.querySelector( + "[data-testid='totp-copy-backup-codes']", + ) as HTMLElement; + copyBtn.click(); + + expect(writeText).toHaveBeenCalledWith("code1\ncode2\ncode3"); + await vi.waitFor(() => { + expect(copyBtn.textContent).toBe("Copied!"); + }); + + overlay.destroy?.(); + }); + it("shows code confirmation input after enable success", async () => { mockTotpEnabled = false; const options = makeOptions(); diff --git a/Client/tauri-client/tests/unit/user-status.test.ts b/Client/tauri-client/tests/unit/user-status.test.ts new file mode 100644 index 00000000..6ae175dc --- /dev/null +++ b/Client/tauri-client/tests/unit/user-status.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { loadUserStatus, saveUserStatus, onUserStatusChange } from "@lib/userStatus"; + +describe("userStatus", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to online with nothing stored", () => { + expect(loadUserStatus()).toBe("online"); + }); + + it("round-trips a saved status", () => { + saveUserStatus("dnd"); + expect(loadUserStatus()).toBe("dnd"); + }); + + it("falls back to online for a value that is not a status", () => { + localStorage.setItem("owncord:settings:userStatus", JSON.stringify("busy")); + expect(loadUserStatus()).toBe("online"); + }); + + it("falls back to online for corrupted storage", () => { + localStorage.setItem("owncord:settings:userStatus", "{not json"); + expect(loadUserStatus()).toBe("online"); + }); + + it("notifies listeners on change and stops after unsubscribe", () => { + const seen = vi.fn(); + const unsub = onUserStatusChange(seen); + + saveUserStatus("idle"); + expect(seen).toHaveBeenCalledWith("idle"); + + unsub(); + saveUserStatus("offline"); + expect(seen).toHaveBeenCalledTimes(1); + }); + + it("ignores unrelated preference changes", () => { + const seen = vi.fn(); + onUserStatusChange(seen, { signal: new AbortController().signal }); + + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { detail: { key: "compactMode" } }), + ); + + expect(seen).not.toHaveBeenCalled(); + }); +}); 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 08e5d996..9afd7d71 100644 --- a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts +++ b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts @@ -135,19 +135,40 @@ describe("VoiceAudioTab camera preview", () => { // --------------------------------------------------------------------------- describe("VoiceAudioTab UI structure", () => { - function stubNavigator( - devices: Array<{ kind: string; deviceId: string; label: string }> = [], - ): void { + /** Fires the `devicechange` listeners registered on the stubbed MediaDevices. */ + let emitDeviceChange: () => void = () => {}; + + function stubNavigator(devices: Array<{ kind: string; deviceId: string; label: string }> = []): { + setDevices(next: Array<{ kind: string; deviceId: string; label: string }>): void; + } { const audioStream = { getTracks: () => [{ stop: vi.fn() }], } as unknown as MediaStream; + let current = devices; + const listeners = new Set<() => void>(); + emitDeviceChange = () => { + for (const l of listeners) l(); + }; + vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue(devices), + enumerateDevices: vi.fn().mockImplementation(() => Promise.resolve(current)), getUserMedia: vi.fn().mockResolvedValue(audioStream), + addEventListener: (type: string, handler: () => void) => { + if (type === "devicechange") listeners.add(handler); + }, + removeEventListener: (_type: string, handler: () => void) => { + listeners.delete(handler); + }, }, }); + + return { + setDevices(next) { + current = next; + }, + }; } beforeEach(() => { @@ -272,6 +293,65 @@ describe("VoiceAudioTab UI structure", () => { ac.abort(); }); + it("refreshes the device lists when hardware is plugged or unplugged", async () => { + const nav = stubNavigator([ + { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }, + { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }, + ]); + localStorage.setItem("owncord:settings:audioInputDevice", JSON.stringify("mic-1")); + + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + const el = tab.build(); + document.body.appendChild(el); + + const inputSelect = el.querySelectorAll("select")[0]!; + await vi.waitFor(() => { + expect(inputSelect.querySelectorAll("option").length).toBe(2); + expect(inputSelect.value).toBe("mic-1"); + }); + + // Unplug mic-1, plug in mic-2. A stale list would keep offering a device + // that no longer exists. + nav.setDevices([ + { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" }, + { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }, + ]); + emitDeviceChange(); + + await vi.waitFor(() => { + const values = Array.from(inputSelect.querySelectorAll("option")).map((o) => o.value); + expect(values).toEqual(["", "mic-2"]); + }); + // The saved device is gone — fall back to Default rather than a dead entry. + expect(inputSelect.value).toBe(""); + + ac.abort(); + }); + + it("stops refreshing device lists once the tab is aborted", async () => { + const nav = stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]); + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + const el = tab.build(); + document.body.appendChild(el); + + const inputSelect = el.querySelectorAll("select")[0]!; + await vi.waitFor(() => { + expect(inputSelect.querySelectorAll("option").length).toBe(2); + }); + + ac.abort(); + nav.setDevices([ + { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }, + { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" }, + ]); + emitDeviceChange(); + await new Promise((r) => setTimeout(r, 10)); + + expect(inputSelect.querySelectorAll("option").length).toBe(2); + }); + it("populates device lists from enumerateDevices", async () => { stubNavigator([ { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }, diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts deleted file mode 100644 index 209409c6..00000000 --- a/Client/tauri-client/tests/unit/voice-channel.test.ts +++ /dev/null @@ -1,933 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// --------------------------------------------------------------------------- -// Mocks — must be declared before importing VoiceChannel -// --------------------------------------------------------------------------- - -const mockAttachStreamPreview = vi.fn(); -const mockAttachScrollCollapse = vi.fn(); - -vi.mock("@lib/streamPreview", () => ({ - attachStreamPreview: (...args: unknown[]) => mockAttachStreamPreview(...args), - attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args), -})); - -// --------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------- - -import { createVoiceChannel } from "../../src/components/VoiceChannel"; -import { voiceStore } from "../../src/stores/voice.store"; -import { membersStore } from "../../src/stores/members.store"; -import { authStore } from "../../src/stores/auth.store"; -import type { VoiceUser } from "../../src/stores/voice.store"; - -function resetStores(): void { - voiceStore.setState(() => ({ - currentChannelId: null, - voiceUsers: new Map(), - voiceConfigs: new Map(), - localMuted: false, - localDeafened: false, - localCamera: false, - localScreenshare: false, - joinedAt: null, - listenOnly: false, - voiceStatus: "idle", - })); - membersStore.setState(() => ({ - members: new Map(), - typingUsers: new Map(), - })); - authStore.setState(() => ({ - token: null, - user: null, - serverName: null, - motd: null, - isAuthenticated: false, - })); -} - -function setVoiceUsers(channelId: number, users: VoiceUser[]): void { - const userMap = new Map(); - for (const u of users) { - userMap.set(u.userId, u); - } - voiceStore.setState((prev) => { - const voiceUsers = new Map(prev.voiceUsers); - voiceUsers.set(channelId, userMap); - return { ...prev, voiceUsers }; - }); -} - -function addMember(id: number, username: string): void { - membersStore.setState((prev) => { - const members = new Map(prev.members); - members.set(id, { - id, - username, - avatar: null, - role: "member", - status: "online", - }); - return { ...prev, members }; - }); -} - -describe("VoiceChannel", () => { - let container: HTMLDivElement; - - beforeEach(() => { - resetStores(); - mockAttachStreamPreview.mockClear(); - mockAttachScrollCollapse.mockClear(); - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - container.remove(); - // Clean up context menus attached to body - document.querySelectorAll(".context-menu").forEach((el) => el.remove()); - }); - - it("renders channel name and voice icon", () => { - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const name = result.element.querySelector(".ch-name"); - expect(name?.textContent).toBe("Voice Lobby"); - - const icon = result.element.querySelector(".ch-icon"); - expect(icon).not.toBeNull(); - - result.destroy(); - }); - - it("calls onJoin when channel item is clicked", () => { - const onJoin = vi.fn(); - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin, - }); - container.appendChild(result.element); - - const channelItem = result.element.querySelector(".channel-item") as HTMLElement; - channelItem.click(); - expect(onJoin).toHaveBeenCalledOnce(); - - result.destroy(); - }); - - it("renders voice users from store", () => { - membersStore.setState((prev) => { - const members = new Map(prev.members); - members.set(10, { - id: 10, - username: "Alice", - avatar: null, - role: "member", - status: "online", - }); - return { ...prev, members }; - }); - - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userItems = result.element.querySelectorAll(".voice-user-item"); - expect(userItems.length).toBe(1); - - const userName = result.element.querySelector(".vu-name"); - expect(userName?.textContent).toBe("Alice"); - - result.destroy(); - }); - - it("marks channel active when users are present", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const channelItem = result.element.querySelector(".channel-item"); - expect(channelItem!.classList.contains("active")).toBe(true); - - result.destroy(); - }); - - it("shows muted icon for muted users", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: true, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const mutedIcon = result.element.querySelector(".vu-muted"); - expect(mutedIcon).not.toBeNull(); - - result.destroy(); - }); - - it("shows speaking class for speaking users", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: true, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userItem = result.element.querySelector(".voice-user-item"); - expect(userItem!.classList.contains("speaking")).toBe(true); - - result.destroy(); - }); - - it("shows no users when channel is empty", () => { - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userItems = result.element.querySelectorAll(".voice-user-item"); - expect(userItems.length).toBe(0); - - const channelItem = result.element.querySelector(".channel-item"); - expect(channelItem!.classList.contains("active")).toBe(false); - - result.destroy(); - }); - - // ── Deafened user icon ── - - it("shows headphones-off icon for deafened users", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: true, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - // Deafened shows .vu-muted (headphones-off icon) - const mutedIcon = result.element.querySelector(".vu-muted"); - expect(mutedIcon).not.toBeNull(); - - result.destroy(); - }); - - // ── Camera icon ── - - it("shows camera icon for user with active camera", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: true, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const statusIcon = result.element.querySelector(".vu-status"); - expect(statusIcon).not.toBeNull(); - - result.destroy(); - }); - - // ── User avatar initial and color ── - - it("renders first-letter avatar with deterministic background color", () => { - addMember(10, "Zara"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Zara", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const avatar = result.element.querySelector(".vu-avatar"); - expect(avatar).not.toBeNull(); - expect(avatar!.textContent).toBe("Z"); - expect((avatar as HTMLElement).style.background).not.toBe(""); - - result.destroy(); - }); - - it("shows '?' avatar for user with empty username", () => { - addMember(11, ""); - setVoiceUsers(1, [ - { - userId: 11, - username: "", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const avatar = result.element.querySelector(".vu-avatar"); - expect(avatar!.textContent).toBe("?"); - - result.destroy(); - }); - - // ── "Unknown" username fallback ── - - it("shows 'Unknown' for user not in members store", () => { - // User 99 has no entry in members store - setVoiceUsers(1, [ - { - userId: 99, - username: "", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const name = result.element.querySelector(".vu-name"); - expect(name?.textContent).toBe("Unknown"); - - result.destroy(); - }); - - // ── Multiple users rendered ── - - it("renders multiple voice users under the same channel", () => { - addMember(10, "Alice"); - addMember(20, "Bob"); - - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - { - userId: 20, - username: "Bob", - muted: true, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userItems = result.element.querySelectorAll(".voice-user-item"); - expect(userItems.length).toBe(2); - - const names = Array.from(userItems).map((el) => el.querySelector(".vu-name")?.textContent); - expect(names).toContain("Alice"); - expect(names).toContain("Bob"); - - result.destroy(); - }); - - // ── Update skips redundant re-render ── - - it("update() skips re-render when voice users map reference is unchanged", () => { - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - // Capture original DOM node reference - const originalRow = result.element.querySelector(".voice-user-item"); - expect(originalRow).not.toBeNull(); - - // Call update again with same store state (no change) - result.update(); - - // The same DOM node should still be there (not destroyed and recreated) - const currentRow = result.element.querySelector(".voice-user-item"); - expect(currentRow).toBe(originalRow); - - result.destroy(); - }); - - // ── Store subscription triggers update ── - - it("re-renders when voice store changes after initial render", () => { - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - // Initially no users - expect(result.element.querySelectorAll(".voice-user-item").length).toBe(0); - - // Add a user to the store - addMember(30, "Charlie"); - setVoiceUsers(1, [ - { - userId: 30, - username: "Charlie", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - voiceStore.flush(); - - // Should now show the user - expect(result.element.querySelectorAll(".voice-user-item").length).toBe(1); - expect(result.element.querySelector(".vu-name")?.textContent).toBe("Charlie"); - - result.destroy(); - }); - - // ── Channel becomes inactive when users leave ── - - it("removes active class when all users leave", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const channelItem = result.element.querySelector(".channel-item"); - expect(channelItem!.classList.contains("active")).toBe(true); - - // Remove all users - voiceStore.setState((prev) => { - const voiceUsers = new Map(prev.voiceUsers); - voiceUsers.delete(1); - return { ...prev, voiceUsers }; - }); - voiceStore.flush(); - - expect(channelItem!.classList.contains("active")).toBe(false); - - result.destroy(); - }); - - // ── Right-click volume context menu ── - - it("right-click on other user row opens volume context menu on document body", () => { - // Set current user different from voice user - authStore.setState(() => ({ - token: "tok", - user: { id: 99, username: "Me", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - }), - ); - - // Menu should be appended to document.body - const menu = document.body.querySelector(".context-menu"); - expect(menu).not.toBeNull(); - - // Should show the username - expect(menu!.textContent).toContain("Alice"); - - // Should have a volume slider - const slider = menu!.querySelector('input[type="range"]') as HTMLInputElement; - expect(slider).not.toBeNull(); - expect(slider.min).toBe("0"); - expect(slider.max).toBe("200"); - - // Should have Reset Volume button - expect(menu!.textContent).toContain("Reset Volume"); - - result.destroy(); - }); - - it("does not show volume context menu when right-clicking own user row", () => { - // Set current user to same ID as voice user - authStore.setState(() => ({ - token: "tok", - user: { id: 10, username: "Alice", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - }), - ); - - // No context menu should appear - const menu = document.body.querySelector(".context-menu"); - expect(menu).toBeNull(); - - result.destroy(); - }); - - // ── Destroy cleanup ── - - it("destroy cleans up context menu if one is open", () => { - authStore.setState(() => ({ - token: "tok", - user: { id: 99, username: "Me", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - // Open context menu - const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - }), - ); - - expect(document.body.querySelector(".context-menu")).not.toBeNull(); - - // Destroy should clean up the menu - result.destroy(); - - expect(document.body.querySelector(".context-menu")).toBeNull(); - }); - - // ── Members store update triggers re-render ── - - it("re-renders when members store updates (username change)", () => { - addMember(10, "OldName"); - setVoiceUsers(1, [ - { - userId: 10, - username: "OldName", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - expect(result.element.querySelector(".vu-name")?.textContent).toBe("OldName"); - - // Update member name in store - membersStore.setState((prev) => { - const members = new Map(prev.members); - members.set(10, { - id: 10, - username: "NewName", - avatar: null, - role: "member", - status: "online", - }); - return { ...prev, members }; - }); - membersStore.flush(); - - expect(result.element.querySelector(".vu-name")?.textContent).toBe("NewName"); - - result.destroy(); - }); - - // ── Stream preview attachment ── - - describe("stream preview", () => { - it("attaches stream preview for remote user with active camera", () => { - authStore.setState(() => ({ - token: "tok", - user: { id: 99, username: "Me", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: true, - screenshare: false, - }, - ]); - - const onWatch = vi.fn(); - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - onClickWatch: onWatch, - }); - container.appendChild(result.element); - - expect(mockAttachStreamPreview).toHaveBeenCalledTimes(1); - // Verify key args: row element, userId, username, screenshare, camera - const args = mockAttachStreamPreview.mock.calls[0]!; - expect(args[1]).toBe(10); // userId - expect(args[2]).toBe("Alice"); // username - expect(args[3]).toBe(false); // hasScreenshare - expect(args[4]).toBe(true); // hasCamera - - result.destroy(); - }); - - it("does not attach stream preview for own user", () => { - authStore.setState(() => ({ - token: "tok", - user: { id: 10, username: "Alice", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: true, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - onClickWatch: vi.fn(), - }); - container.appendChild(result.element); - - expect(mockAttachStreamPreview).not.toHaveBeenCalled(); - - result.destroy(); - }); - - it("does not attach stream preview when user has no camera or screenshare", () => { - authStore.setState(() => ({ - token: "tok", - user: { id: 99, username: "Me", avatar: null, role: "member" }, - serverName: null, - motd: null, - isAuthenticated: true, - })); - - addMember(10, "Alice"); - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - onClickWatch: vi.fn(), - }); - container.appendChild(result.element); - - expect(mockAttachStreamPreview).not.toHaveBeenCalled(); - - result.destroy(); - }); - - it("attaches scroll collapse on voice-users-list container", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: false, - deafened: false, - speaking: false, - camera: true, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - onClickWatch: vi.fn(), - }); - container.appendChild(result.element); - - expect(mockAttachScrollCollapse).toHaveBeenCalledTimes(1); - const args = mockAttachScrollCollapse.mock.calls[0]!; - expect(args[0]).toBeInstanceOf(HTMLElement); - expect((args[0] as HTMLElement).classList.contains("voice-users-list")).toBe(true); - - result.destroy(); - }); - }); - - // ── User both muted and deafened ── - - it("shows deafened icon when user is both muted and deafened", () => { - setVoiceUsers(1, [ - { - userId: 10, - username: "Alice", - muted: true, - deafened: true, - speaking: false, - camera: false, - screenshare: false, - }, - ]); - - const result = createVoiceChannel({ - channelId: 1, - channelName: "Voice Lobby", - onJoin: vi.fn(), - }); - container.appendChild(result.element); - - // When deafened, the component shows headphones-off (deafened takes precedence in the conditional) - const mutedIcon = result.element.querySelector(".vu-muted"); - expect(mutedIcon).not.toBeNull(); - - result.destroy(); - }); -}); diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index 1ccc7631..dc0f17a1 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -129,6 +129,7 @@ describe("VoiceWidget", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels }; }); @@ -162,6 +163,7 @@ describe("VoiceWidget", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels }; }); @@ -195,6 +197,7 @@ describe("VoiceWidget", () => { unreadCount: 0, lastMessageId: null, canSend: true, + slowMode: 0, }); return { ...prev, channels }; }); diff --git a/Client/tauri-client/tests/unit/ws-cert.test.ts b/Client/tauri-client/tests/unit/ws-cert.test.ts new file mode 100644 index 00000000..0351641e --- /dev/null +++ b/Client/tauri-client/tests/unit/ws-cert.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// vi.mock is hoisted per file; the factories resolve to the shared handles +// exported from ./helpers/ws-mocks (see that module's doc comment). +vi.mock("@tauri-apps/api/core", async () => ({ + invoke: (await import("./helpers/ws-mocks")).mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", async () => ({ + listen: (await import("./helpers/ws-mocks")).mockListen, +})); + +import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; +import { createWsClient } from "../../src/lib/ws"; + +describe("cert mismatch blocking", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("should block reconnect when cert mismatch detected", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Cert mismatch event fires + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + expect(client.getState()).toBe("disconnected"); + + // Connection closes after mismatch + emitTauriEvent("ws-state", "closed"); + + // Wait well beyond normal backoff — should NOT reconnect + 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); + 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: "", + }, + }), + ); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + expect(client.getState()).toBe("disconnected"); + + // Accept the new fingerprint + await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); + + // Now a manual reconnect should work + mockInvoke.mockClear(); + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); + }); + + it("routes first_use cert events to onCertFirstUse, not onCertMismatch (F4/F8)", async () => { + const firstUse: unknown[] = []; + const mismatch: unknown[] = []; + client.onCertFirstUse((e) => firstUse.push(e)); + client.onCertMismatch((e) => mismatch.push(e)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "first_use", + }); + + expect(firstUse).toHaveLength(1); + expect(mismatch).toHaveLength(0); + }); + + it("startCertListener catches cert events before any WS connect (connect-page path)", async () => { + const firstUse: unknown[] = []; + client.onCertFirstUse((e) => firstUse.push(e)); + + // No connect() — main.ts registers the listener at bootstrap so first-use + // fires during the connect page's health check, before login. + await client.startCertListener(); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "first_use", + }); + + expect(firstUse).toHaveLength(1); + }); + + it("should not schedule reconnect when certMismatchBlock is true", 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", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Trigger mismatch + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:CHANGED", + status: "mismatch", + message: "Stored: sha256:ORIGINAL", + }); + + expect(mismatchEvents).toHaveLength(1); + + // Connection drops + emitTauriEvent("ws-state", "closed"); + + // State should remain disconnected, not reconnecting + expect(client.getState()).toBe("disconnected"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); + + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects).toHaveLength(0); + }); +}); + +describe("parseStoredFingerprint", () => { + // Import the pure function directly + let parseStoredFingerprint: typeof import("../../src/lib/ws").parseStoredFingerprint; + + beforeEach(async () => { + const mod = await import("../../src/lib/ws"); + parseStoredFingerprint = mod.parseStoredFingerprint; + }); + + it("returns undefined for undefined input", () => { + expect(parseStoredFingerprint(undefined)).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(parseStoredFingerprint("")).toBeUndefined(); + }); + + it("returns undefined when no Stored: prefix found", () => { + expect(parseStoredFingerprint("no match here")).toBeUndefined(); + }); + + it("extracts fingerprint after Stored: prefix", () => { + expect(parseStoredFingerprint("Stored: sha256:ABCDEF")).toBe("sha256:ABCDEF"); + }); + + it("extracts first non-whitespace token after Stored:", () => { + expect(parseStoredFingerprint("Stored: sha256:XYZ trailing")).toBe("sha256:XYZ"); + }); + + it("extracts fingerprint from longer message string", () => { + expect(parseStoredFingerprint("Certificate mismatch. Stored: sha256:OLD123")).toBe( + "sha256:OLD123", + ); + }); +}); + +describe("cert-tofu non-mismatch statuses", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("trusted_first_use status does not block reconnect", 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: "", + }, + }), + ); + + // Non-mismatch cert event + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:FIRST", + status: "trusted_first_use", + }); + + // State should still be connected (not disconnected) + expect(client.getState()).toBe("connected"); + + // Verify mismatch listener was NOT called + const mismatchEvents: unknown[] = []; + client.onCertMismatch((e) => mismatchEvents.push(e)); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:TRUSTED", + status: "trusted", + }); + + expect(mismatchEvents).toHaveLength(0); + expect(client.getState()).toBe("connected"); + }); +}); + +describe("acceptCertFingerprint edge cases", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("calls Tauri invoke with correct command and args", async () => { + // Must connect first so Tauri APIs are loaded + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + await client.acceptCertFingerprint("example.com", "sha256:NEWCERT"); + + expect(mockInvoke).toHaveBeenCalledWith("accept_cert_fingerprint", { + host: "example.com", + fingerprint: "sha256:NEWCERT", + }); + }); + + it("clears certMismatchBlock so reconnect works again", 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: "", + }, + }), + ); + + // Block with mismatch + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + }); + expect(client.getState()).toBe("disconnected"); + + // Accept fingerprint + await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); + + // Reconnect should now work + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + expect(client.getState()).toBe("connecting"); + }); +}); + +describe("disconnect resets certMismatchBlock", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("clears certMismatchBlock on intentional 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: "", + }, + }), + ); + + // Set cert mismatch block + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + }); + + // Intentional disconnect should clear the block + client.disconnect(); + + // Now reconnect should work (certMismatchBlock was cleared) + mockInvoke.mockClear(); + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); + expect(client.getState()).toBe("connecting"); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws-lifecycle.test.ts b/Client/tauri-client/tests/unit/ws-lifecycle.test.ts new file mode 100644 index 00000000..952a8bc9 --- /dev/null +++ b/Client/tauri-client/tests/unit/ws-lifecycle.test.ts @@ -0,0 +1,1025 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { ConnectionState } from "../../src/lib/ws"; + +// vi.mock is hoisted per file; the factories resolve to the shared handles +// exported from ./helpers/ws-mocks (see that module's doc comment). +vi.mock("@tauri-apps/api/core", async () => ({ + invoke: (await import("./helpers/ws-mocks")).mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", async () => ({ + listen: (await import("./helpers/ws-mocks")).mockListen, +})); + +import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; +import { createWsClient } from "../../src/lib/ws"; +import { addLogListener, type LogEntry } from "../../src/lib/logger"; + +describe("WebSocket Client (Tauri proxy)", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("starts in disconnected state", () => { + expect(client.getState()).toBe("disconnected"); + }); + + it("transitions to connecting on connect", async () => { + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + expect(states).toContain("connecting"); + }); + + it("calls ws_connect with correct URL", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", { + url: "wss://localhost:8443/api/v1/ws", + }); + }); + + it("sends auth message when Rust reports open", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + + // Simulate Rust reporting connection open + emitTauriEvent("ws-state", "open"); + + // Should call ws_send with auth message + expect(mockInvoke).toHaveBeenCalledWith( + "ws_send", + expect.objectContaining({ + message: expect.stringContaining('"type":"auth"'), + }), + ); + }); + + it("transitions to connected on auth_ok", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "Test", + motd: "Hello", + }, + }), + ); + + expect(states).toContain("connected"); + }); + + it("dispatches messages to typed listeners", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (payload) => messages.push(payload)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, + attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + }), + ); + + expect(messages).toHaveLength(1); + }); + + it("unsubscribe removes listener", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + const unsub = client.on("chat_message", (payload) => messages.push(payload)); + unsub(); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, + attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + }), + ); + + expect(messages).toHaveLength(0); + }); + + it("auth_error does NOT trigger reconnect", async () => { + client.connect({ host: "localhost:8443", token: "bad-token" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const authErrors: unknown[] = []; + client.on("auth_error", (payload) => authErrors.push(payload)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_error", + payload: { message: "Invalid token" }, + }), + ); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(authErrors).toHaveLength(1); + expect(client.getState()).toBe("disconnected"); + }); + + it("reconnects on unexpected close with backoff", 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: "", + }, + }), + ); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + // Simulate connection closed by Rust proxy + emitTauriEvent("ws-state", "closed"); + + expect(states).toContain("reconnecting"); + + // After 1s backoff, should call ws_connect again + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); + }); + + it("send returns correlation ID", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + + expect(id).toBe("test-uuid-1234"); + }); + + it("drops oversized messages", async () => { + client.connect({ + host: "localhost:8443", + token: "t", + maxMessageSizeBytes: 50, + }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + const bigData = JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "x".repeat(100), + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }); + + emitTauriEvent("ws-message", bigData); + expect(messages).toHaveLength(0); + }); + + it("drops malformed JSON", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent("ws-message", "not-json{{{"); + expect(messages).toHaveLength(0); + }); + + it("does not log raw frame content on parse failure (no plaintext leak)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const entries: LogEntry[] = []; + const remove = addLogListener((e) => entries.push(e)); + const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9"; + emitTauriEvent("ws-message", secret + " not-json{{{"); + remove(); + + // The decrypted frame must never reach the (on-disk-persisted) log... + expect(JSON.stringify(entries)).not.toContain(secret); + // ...but the parse failure is still recorded so it stays debuggable. + expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true); + }); + + it("disconnect prevents reconnect", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + client.disconnect(); + + await vi.advanceTimersByTimeAsync(60_000); + expect(client.getState()).toBe("disconnected"); + }); +}); + +describe("heartbeat", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("sends heartbeat ping every 30 seconds after auth_ok", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + mockInvoke.mockClear(); + + // Advance 30 seconds — should send a ping + await vi.advanceTimersByTimeAsync(30_000); + + const pingSends = mockInvoke.mock.calls.filter( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), + ); + expect(pingSends.length).toBeGreaterThanOrEqual(1); + }); + + it("stops heartbeat on 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", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + client.disconnect(); + mockInvoke.mockClear(); + + // No heartbeat should be sent after disconnect + await vi.advanceTimersByTimeAsync(60_000); + + const pingSends = mockInvoke.mock.calls.filter( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), + ); + expect(pingSends).toHaveLength(0); + }); +}); + +describe("setState deduplication", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("does not notify listeners when state is already the same", async () => { + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // State is now "connecting". Count how many times "connecting" appeared. + const connectingCount = states.filter((s) => s === "connecting").length; + expect(connectingCount).toBe(1); + }); + + it("notifies listeners when state actually changes", async () => { + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + 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: "", + }, + }), + ); + + // Should have transitioned: connecting -> authenticating -> connected + expect(states).toContain("connecting"); + expect(states).toContain("authenticating"); + expect(states).toContain("connected"); + }); +}); + +describe("getReconnectDelay boundary and arithmetic", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("first reconnect delay is 1000ms (1000 * 2^0)", 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("ws-state", "closed"); + mockInvoke.mockClear(); + + // At 999ms, should NOT have reconnected yet + await vi.advanceTimersByTimeAsync(999); + const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(callsBefore).toHaveLength(0); + + // At 1000ms total, should reconnect + await vi.advanceTimersByTimeAsync(1); + const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(callsAfter).toHaveLength(1); + }); + + it("second reconnect delay is 2000ms (1000 * 2^1)", 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: "", + }, + }), + ); + + // First drop + reconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + // Don't send auth_ok, so reconnectAttempt stays incremented + // Simulate another close immediately + emitTauriEvent("ws-state", "closed"); + + mockInvoke.mockClear(); + + // Second attempt should have 2000ms delay + await vi.advanceTimersByTimeAsync(1999); + const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(callsBefore).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1); + const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(callsAfter).toHaveLength(1); + }); + + it("delay uses default 30000ms cap when maxReconnectDelayMs not set", 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: "", + }, + }), + ); + + // Simulate many drops to ramp up backoff + for (let i = 0; i < 10; i++) { + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(31_000); + } + + // After 10 attempts, uncapped delay would be 1000*2^10 = 1024000ms + // But it should be capped at 30000ms (default) + mockInvoke.mockClear(); + emitTauriEvent("ws-state", "closed"); + + // Should reconnect within 30s (capped), not 1024s + await vi.advanceTimersByTimeAsync(30_001); + const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(calls.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("wsGeneration stale listener guard", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("ignores events from stale generation after new connect()", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // Capture the handlers registered in the first connect + const oldMsgHandlers = [...(eventHandlers.get("ws-message") ?? [])]; + const oldStateHandlers = [...(eventHandlers.get("ws-state") ?? [])]; + + // Start a new connection (increments wsGeneration, cleans up old handlers) + client.connect({ host: "localhost:8443", token: "t2" }); + await vi.advanceTimersByTimeAsync(10); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + // If any old handlers survived cleanup, calling them should be a no-op + // because gen !== wsGeneration + for (const h of oldMsgHandlers) { + h({ + payload: JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + }); + } + + for (const h of oldStateHandlers) { + h({ payload: "open" }); + } + + // State should NOT have changed to connected from stale handlers + expect(states).not.toContain("connected"); + }); +}); + +describe("heartbeat proxyOpen guard", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("does not send ping when proxyOpen is false (connection dropped mid-heartbeat)", 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: "", + }, + }), + ); + + // Heartbeat started. Now close the proxy (sets proxyOpen=false) + emitTauriEvent("ws-state", "closed"); + + // Clear mocks and advance past heartbeat interval + mockInvoke.mockClear(); + + // The heartbeat was stopped by close handler, so no pings should fire + await vi.advanceTimersByTimeAsync(35_000); + + const pings = mockInvoke.mock.calls.filter( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), + ); + expect(pings).toHaveLength(0); + }); +}); + +describe("connect when Tauri APIs unavailable", () => { + it("falls back to disconnected when ensureTauriApis fails", async () => { + vi.useFakeTimers(); + + // Create a fresh client that will try to load Tauri APIs fresh + // The mock is already set up to resolve, so we need to simulate unavailability + // by making tauriInvoke null after ensureTauriApis + const origInvoke = mockInvoke; + + // Temporarily clear the mock module to simulate Tauri not available + // We test this indirectly: if ws_connect is never called but state + // goes back to disconnected, the guard worked + const client2 = createWsClient(); + const states: ConnectionState[] = []; + client2.onStateChange((s) => states.push(s)); + + client2.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // With the mock in place, it should proceed normally + expect(states).toContain("connecting"); + + client2.disconnect(); + vi.useRealTimers(); + }); +}); + +describe("cleanupEventListeners edge cases", () => { + let client: ReturnType; + // Save original mockListen implementation to restore after override tests + let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R + ? R + : never; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + originalMockListenImpl = mockListen.getMockImplementation()!; + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + // Restore the original mockListen implementation so later tests work + mockListen.mockImplementation(originalMockListenImpl!); + vi.useRealTimers(); + }); + + it("handles unsub functions that return rejected promises", async () => { + // Override mockListen to return an unsub that returns a rejected promise + mockListen.mockImplementation( + async (_event: string, _handler: (e: { payload: unknown }) => void) => { + return () => { + return Promise.reject(new Error("resource invalidated")); + }; + }, + ); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // Disconnect triggers cleanupEventListeners — should not crash + client.disconnect(); + await vi.advanceTimersByTimeAsync(10); + + expect(client.getState()).toBe("disconnected"); + }); + + it("handles unsub functions that throw synchronously", async () => { + mockListen.mockImplementation( + async (_event: string, _handler: (e: { payload: unknown }) => void) => { + return () => { + throw new Error("sync unsub error"); + }; + }, + ); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // Should not crash + client.disconnect(); + expect(client.getState()).toBe("disconnected"); + }); +}); + +describe("dedup does not filter auth_ok, auth_error, or ready during replay", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("ready message is not deduped during replay", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 10, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Disconnect and reconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + expect(client.isReplaying()).toBe(true); + + const readyPayloads: unknown[] = []; + client.on("ready", (p) => readyPayloads.push(p)); + + // Send ready during replay BEFORE auth_ok — should NOT be deduped + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "ready", + seq: 11, + payload: { + channels: [], + members: [], + voice_states: [], + roles: [], + }, + }), + ); + + expect(readyPayloads).toHaveLength(1); + + // Send ready again with same seq — ready is exempt from dedup, so it passes + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "ready", + seq: 11, + payload: { + channels: [], + members: [], + voice_states: [], + roles: [], + }, + }), + ); + + expect(readyPayloads).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// Listener registry mechanics (no Tauri connection needed) +// --------------------------------------------------------------------------- + +describe("listener registry mechanics (on/off/dispatch)", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("on() registers a listener and returns an unsubscribe function", () => { + const listener = vi.fn(); + const unsub = client.on("chat_message", listener); + expect(typeof unsub).toBe("function"); + }); + + it("off via returned unsubscribe removes a specific listener", async () => { + // Connect so we can dispatch messages through the proxy + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const calls: string[] = []; + const listenerA = () => calls.push("A"); + const listenerB = () => calls.push("B"); + + client.on("chat_message", listenerA); + const unsubB = client.on("chat_message", listenerB); + + // Remove only B + unsubB(); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + expect(calls).toEqual(["A"]); + }); + + it("multiple listeners on the same event type all get called", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const calls: string[] = []; + client.on("chat_message", () => calls.push("first")); + client.on("chat_message", () => calls.push("second")); + client.on("chat_message", () => calls.push("third")); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + expect(calls).toEqual(["first", "second", "third"]); + }); + + it("listener removal mid-dispatch does not crash", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const calls: string[] = []; + let unsubSelf: (() => void) | null = null; + + // This listener unsubscribes itself when called + unsubSelf = client.on("chat_message", () => { + calls.push("self-removing"); + unsubSelf!(); + }); + + // Second listener should still be called + client.on("chat_message", () => calls.push("survivor")); + + const msgJson = JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }); + + // First dispatch — self-removing listener fires then removes itself + emitTauriEvent("ws-message", msgJson); + expect(calls).toContain("self-removing"); + expect(calls).toContain("survivor"); + + // Second dispatch — only survivor should fire + calls.length = 0; + emitTauriEvent("ws-message", msgJson); + expect(calls).toEqual(["survivor"]); + }); + + it("unknown event type dispatch does not throw", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Dispatch a completely unknown event type — should not crash + expect(() => { + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "totally_unknown_event", + payload: { foo: "bar" }, + }), + ); + }).not.toThrow(); + }); + + it("error boundary: throwing listener does not prevent next listener from running", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const received: string[] = []; + + client.on("chat_message", () => { + throw new Error("first listener explodes"); + }); + client.on("chat_message", (payload) => { + received.push((payload as { content: string }).content); + }); + client.on("chat_message", () => { + throw new Error("third listener also explodes"); + }); + client.on("chat_message", (payload) => { + received.push("fourth:" + (payload as { content: string }).content); + }); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hello", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Both non-throwing listeners should have received the message + expect(received).toEqual(["hello", "fourth:hello"]); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws-messaging.test.ts b/Client/tauri-client/tests/unit/ws-messaging.test.ts new file mode 100644 index 00000000..588a077c --- /dev/null +++ b/Client/tauri-client/tests/unit/ws-messaging.test.ts @@ -0,0 +1,969 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { ConnectionState } from "../../src/lib/ws"; + +// vi.mock is hoisted per file; the factories resolve to the shared handles +// exported from ./helpers/ws-mocks (see that module's doc comment). +vi.mock("@tauri-apps/api/core", async () => ({ + invoke: (await import("./helpers/ws-mocks")).mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", async () => ({ + listen: (await import("./helpers/ws-mocks")).mockListen, +})); + +import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; +import { createWsClient, toConnectionStatus } from "../../src/lib/ws"; + +describe("message handling edge cases", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("silently ignores pong messages", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + // pong has no payload listeners, but we verify no crash + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent("ws-message", JSON.stringify({ type: "pong" })); + expect(messages).toHaveLength(0); + }); + + it("drops messages with missing type", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent("ws-message", JSON.stringify({ payload: { data: "no type" } })); + expect(messages).toHaveLength(0); + }); + + it("drops messages with undefined payload", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent("ws-message", JSON.stringify({ type: "chat_message" })); + expect(messages).toHaveLength(0); + }); + + it("tracks highest seq number (ignores lower seq)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 10, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // seq=50 then seq=30 — should keep 50 + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 50, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 30, + payload: { + id: 2, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hello", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Disconnect and reconnect to verify lastSeq + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(50); + }); + + it("handles message without seq field (defaults to 0)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + // no seq field + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "no seq", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + expect(messages).toHaveLength(1); + }); + + it("dispatch logs when no listeners for message type", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Send a message with no listener registered — should log "no listeners" + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // No crash means the "no listeners" debug log path executed + expect(client.getState()).toBe("connected"); + }); + + it("dispatch catches listener errors", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Register a listener that throws + client.on("chat_message", () => { + throw new Error("listener boom"); + }); + + // Also register a second listener to verify it still runs + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Second listener should still receive the message + expect(messages).toHaveLength(1); + }); + + it("state listener errors are caught", async () => { + client.onStateChange(() => { + throw new Error("state listener boom"); + }); + + // Should not crash + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + expect(client.getState()).toBe("connecting"); + }); + + it("ws-error event is logged without crash", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // Emit a ws-error event + emitTauriEvent("ws-error", "Connection reset by peer"); + + // No crash expected + expect(client.getState()).toBe("connecting"); + }); + + it("isReplaying returns false when not reconnecting", () => { + expect(client.isReplaying()).toBe(false); + }); + + it("_getWs returns null", () => { + expect(client._getWs()).toBeNull(); + }); + + it("onStateChange unsubscribe works", async () => { + const states: ConnectionState[] = []; + const unsub = client.onStateChange((s) => states.push(s)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + expect(states.length).toBeGreaterThan(0); + + const count = states.length; + unsub(); + + emitTauriEvent("ws-state", "open"); + expect(states.length).toBe(count); + }); + + it("onCertMismatch unsubscribe works", async () => { + const events: unknown[] = []; + const unsub = client.onCertMismatch((evt) => events.push(evt)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + unsub(); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + message: "Stored: sha256:OLD", + }); + + expect(events).toHaveLength(0); + }); +}); + +describe("handleMessage size boundary", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("accepts message exactly at size limit", async () => { + const limit = 200; + client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + const msg = { + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }; + const json = JSON.stringify(msg); + // Pad content to make JSON exactly at limit + const padding = limit - json.length; + if (padding > 0) { + msg.payload.content = "x".repeat(padding); + } + const exactJson = JSON.stringify(msg); + // Ensure it is exactly at limit (not over) + expect(exactJson.length).toBeLessThanOrEqual(limit); + + emitTauriEvent("ws-message", exactJson); + expect(messages.length).toBeGreaterThanOrEqual(0); // should not crash + }); + + it("drops message one byte over size limit", async () => { + const limit = 100; + client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + const msg = { + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "x".repeat(limit), // guarantees over limit + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }; + + emitTauriEvent("ws-message", JSON.stringify(msg)); + expect(messages).toHaveLength(0); + }); + + it("uses default 1MB limit when maxMessageSizeBytes not configured", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + // Message under 1MB should pass + const smallMsg = JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "small", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }); + emitTauriEvent("ws-message", smallMsg); + expect(messages).toHaveLength(1); + }); +}); + +describe("dispatch with no listeners for type", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("does not crash when dispatching to type with empty listener set", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Register and immediately unregister a listener + const unsub = client.on("chat_message", () => {}); + unsub(); + + // Now dispatch a message to that type — empty set + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // No crash + expect(true).toBe(true); + }); + + it("dispatches message with id to listener", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const receivedIds: (string | undefined)[] = []; + client.on("chat_message", (_payload, id) => { + receivedIds.push(id); + }); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + id: "correlation-123", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + expect(receivedIds).toEqual(["correlation-123"]); + }); +}); + +describe("on() creates Set for new type", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("creates a listener set for a type that has never been registered", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const results: unknown[] = []; + client.on("presence", (p) => results.push(p)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + payload: { user_id: 1, status: "online" }, + }), + ); + + expect(results).toHaveLength(1); + }); + + it("multiple listeners on same type all receive messages", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const results1: unknown[] = []; + const results2: unknown[] = []; + client.on("typing", (p) => results1.push(p)); + client.on("typing", (p) => results2.push(p)); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "typing", + payload: { channel_id: 1, user_id: 1, username: "a" }, + }), + ); + + expect(results1).toHaveLength(1); + expect(results2).toHaveLength(1); + }); +}); + +describe("send envelope format", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("wraps message with id and serializes to JSON", 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: "", + }, + }), + ); + + mockInvoke.mockClear(); + + client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hello", reply_to: null, attachments: [] }, + }); + + const sendCall = mockInvoke.mock.calls.find((c) => c[0] === "ws_send"); + expect(sendCall).toBeDefined(); + + const sent = JSON.parse((sendCall![1] as { message: string }).message); + expect(sent.type).toBe("chat_send"); + expect(sent.id).toBe("test-uuid-1234"); + expect(sent.payload.channel_id).toBe(1); + expect(sent.payload.content).toBe("hello"); + expect(sent.payload.reply_to).toBeNull(); + expect(sent.payload.attachments).toEqual([]); + }); +}); + +describe("send edge cases", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("send when not connected does not crash (logs warning)", () => { + // Client is disconnected — send should warn but not crash + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + + expect(id).toBe("test-uuid-1234"); + }); + + it("ws_connect failure triggers reconnect", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_connect") throw new Error("connection refused"); + return undefined; + }); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // Should attempt reconnect after failure + expect(states).toContain("reconnecting"); + }); + + it("reconnect with successful auth_ok resets reconnect attempt counter", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Drop connection + emitTauriEvent("ws-state", "closed"); + + // First reconnect (1s backoff) + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 2, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Drop again + emitTauriEvent("ws-state", "closed"); + + // If reconnect counter was reset, delay should be back to 1s (not 2s) + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects.length).toBeGreaterThanOrEqual(1); + }); + + it("ws_send rejection is caught without crash", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Make ws_send reject + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("send failed"); + return undefined; + }); + + // Send should not crash despite ws_send rejection + client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + + // Flush promise to trigger the catch + await vi.advanceTimersByTimeAsync(10); + expect(client.getState()).toBe("connected"); + }); + + it("onSendFailure fires with NETWORK when ws_send hits backpressure (channel full)", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([{ id, code: "NETWORK" }]); + }); + + it("onSendFailure fires with OFFLINE when ws_send reports the channel closed", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel closed"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([{ id, code: "OFFLINE" }]); + }); + + it("onSendFailure fires with OFFLINE when sending while the proxy is not open", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Drop the proxy: subsequent sends take the not-open early return. + emitTauriEvent("ws-state", "closed"); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + // The early-return notification is deferred a microtask so callers can + // register the id (optimistic row) before the failure lands. + expect(failures).toEqual([]); + await vi.advanceTimersByTimeAsync(0); + + expect(failures).toEqual([{ id, code: "OFFLINE" }]); + }); + + it("heartbeat ping failures do not fire onSendFailure (no envelope id)", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + // Let the 30s heartbeat fire (and its ws_send reject). + await vi.advanceTimersByTimeAsync(30_100); + + expect(failures).toEqual([]); + }); + + it("onSendFailure unsubscribe works", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + const unsub = client.onSendFailure((id, code) => failures.push({ id, code })); + unsub(); + + client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([]); + }); + + it("ws_disconnect error is ignored during disconnectProxy", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Make ws_disconnect throw + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_disconnect") throw new Error("disconnect failed"); + return undefined; + }); + + // Disconnect should not crash + client.disconnect(); + await vi.advanceTimersByTimeAsync(10); + expect(client.getState()).toBe("disconnected"); + }); + + it("reconnect delay is capped by maxReconnectDelayMs", async () => { + client.connect({ + host: "localhost:8443", + token: "t", + maxReconnectDelayMs: 5000, + }); + 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: "", + }, + }), + ); + + // Force multiple reconnect attempts to ramp up backoff + for (let i = 0; i < 5; i++) { + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(10_000); // well past any backoff + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: i + 2, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + } + + // At this point, the reconnect delay should be capped at 5000ms + // The fact that the loop completed without hanging proves capping works + expect(client.getState()).toBe("connected"); + }); +}); + +describe("toConnectionStatus", () => { + it("maps the internal 5-state machine onto the UX-facing 3-state status", () => { + expect(toConnectionStatus("connected")).toBe("connected"); + expect(toConnectionStatus("disconnected")).toBe("disconnected"); + // Mid-retry states must read as "reconnecting", not "disconnected" — + // a reconnect cycle passes through connecting/authenticating. + expect(toConnectionStatus("reconnecting")).toBe("reconnecting"); + expect(toConnectionStatus("connecting")).toBe("reconnecting"); + expect(toConnectionStatus("authenticating")).toBe("reconnecting"); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws-reconnect.test.ts b/Client/tauri-client/tests/unit/ws-reconnect.test.ts new file mode 100644 index 00000000..7b658ee8 --- /dev/null +++ b/Client/tauri-client/tests/unit/ws-reconnect.test.ts @@ -0,0 +1,948 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// vi.mock is hoisted per file; the factories resolve to the shared handles +// exported from ./helpers/ws-mocks (see that module's doc comment). +vi.mock("@tauri-apps/api/core", async () => ({ + invoke: (await import("./helpers/ws-mocks")).mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", async () => ({ + listen: (await import("./helpers/ws-mocks")).mockListen, +})); + +import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; +import { createWsClient } from "../../src/lib/ws"; + +describe("lastSeq tracking", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("should start with lastSeq = 0", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + // When open fires, auth message should contain last_seq: 0 + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + expect(authCall).toBeDefined(); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(0); + }); + + it("should update lastSeq from seq field in incoming messages", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Send auth_ok so we're connected + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Send a message with seq 42 + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 42, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Now simulate a disconnect + reconnect to verify lastSeq was updated + emitTauriEvent("ws-state", "closed"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); // backoff + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + expect(authCall).toBeDefined(); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(42); + }); + + it("should send last_seq in auth message on reconnect", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Disconnect unexpectedly + emitTauriEvent("ws-state", "closed"); + + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + expect(authCall).toBeDefined(); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(5); + }); + + it("should preserve lastSeq across auto-reconnects", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 10, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // First auto-reconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + // Receive more messages with higher seq + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 11, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 25, + payload: { + id: 2, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hello", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Second auto-reconnect + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(2100); // 2nd attempt = 2s backoff + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(25); + }); + + it("should reset lastSeq to 0 on intentional 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", + seq: 50, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Intentional disconnect (e.g. logout) + client.disconnect(); + + // Reconnect fresh + mockInvoke.mockClear(); + client.connect({ host: "localhost:8443", token: "t2" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + expect(authCall).toBeDefined(); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(0); + }); +}); + +describe("reconnection dedup", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("deduplicates messages during reconnection replay", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // Auth and get some messages to advance lastSeq + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Disconnect unexpectedly + emitTauriEvent("ws-state", "closed"); + + // Wait for reconnect + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + // During reconnect, replay dedup is active + expect(client.isReplaying()).toBe(true); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + // Send a message during replay -- first occurrence passes + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Send the SAME message ID again — should be deduped + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Only the first occurrence should pass through + expect(messages).toHaveLength(1); + expect((messages[0] as { content: string }).content).toBe("original"); + }); + + it("auth_ok and ready messages are not deduped during replay", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Disconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + expect(client.isReplaying()).toBe(true); + + const authPayloads: unknown[] = []; + client.on("auth_ok", (p) => authPayloads.push(p)); + + // auth_ok during replay should NOT be deduped + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 6, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + expect(authPayloads).toHaveLength(1); + // After auth_ok, replay dedup should be cleared + expect(client.isReplaying()).toBe(false); + }); + + it("dedup uses type:seq as key when message has no id", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); + + // Disconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const presences: unknown[] = []; + client.on("presence", (p) => presences.push(p)); + + // First presence during replay — passes through + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); + + // Same type:seq — should be deduped + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); + + // Different seq — should pass through + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 11, + payload: { user_id: 1, status: "online" }, + }), + ); + + expect(presences).toHaveLength(2); + expect((presences[0] as { status: string }).status).toBe("idle"); + expect((presences[1] as { status: string }).status).toBe("online"); + }); + + it("dedup is not active for first connection (lastSeq=0)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + // First connect should NOT enable dedup + expect(client.isReplaying()).toBe(false); + }); +}); + +describe("seq tracking boundary conditions", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("does NOT update lastSeq when seq equals current lastSeq (> not >=)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 10, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Send message with same seq=10 — should NOT change lastSeq + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 10, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "same seq", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Verify lastSeq is still 10 via reconnect auth message + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(10); + }); + + it("treats non-number seq as 0", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Send message with string seq — treated as 0, should not reduce lastSeq + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: "not-a-number", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "bad seq", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + const authMsg = JSON.parse((authCall![1] as { message: string }).message); + expect(authMsg.payload.last_seq).toBe(5); + }); +}); + +describe("scheduleReconnect guard clauses", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("does not reconnect when intentionalClose is true (disconnect called)", 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: "", + }, + }), + ); + + // Intentional disconnect sets intentionalClose=true + client.disconnect(); + mockInvoke.mockClear(); + + await vi.advanceTimersByTimeAsync(60_000); + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects).toHaveLength(0); + expect(client.getState()).toBe("disconnected"); + }); + + it("does not reconnect when certMismatchBlock is true", 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: "", + }, + }), + ); + + // Trigger cert mismatch + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "mismatch", + }); + + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + + await vi.advanceTimersByTimeAsync(60_000); + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects).toHaveLength(0); + }); + + it("reconnect timer callback bails out safely when config is cleared", 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: "", + }, + }), + ); + + // Unexpected close schedules reconnect. + emitTauriEvent("ws-state", "closed"); + + // Simulate config being cleared before timer callback executes. + client.disconnect(); + mockInvoke.mockClear(); + + await vi.advanceTimersByTimeAsync(2_000); + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects).toHaveLength(0); + expect(client.getState()).toBe("disconnected"); + }); +}); + +describe("dedup eviction when exceeding MAX_DEDUP_SIZE", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("evicts oldest entry when dedup set exceeds 1000 entries", async () => { + client.connect({ host: "localhost:8443", 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: "", + }, + }), + ); + + // Get past lastSeq > 0 condition + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 100, + payload: { + id: 99, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "bump seq", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + + // Disconnect to trigger dedup mode + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + expect(client.isReplaying()).toBe(true); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + // Send 1002 unique messages to trigger eviction (MAX_DEDUP_SIZE = 1000) + for (let i = 0; i < 1002; i++) { + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 101 + i, + id: `msg-${i}`, + payload: { + id: i, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: `msg ${i}`, + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + } + + // All 1002 should have been dispatched (first occurrence of each) + expect(messages).toHaveLength(1002); + + // Now re-send the very first message (msg-0) — it was evicted, so it should pass again + const countBefore = messages.length; + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 101, + id: "msg-0", + payload: { + id: 0, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "msg 0", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); + expect(messages).toHaveLength(countBefore + 1); + }); +}); + +describe("auth_error during reconnection replay", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("auth_error is not deduped during replay and stops reconnect", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Disconnect + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + expect(client.isReplaying()).toBe(true); + + const errors: unknown[] = []; + client.on("auth_error", (p) => errors.push(p)); + + // auth_error during replay — should NOT be deduped + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_error", + payload: { message: "Token expired" }, + }), + ); + + expect(errors).toHaveLength(1); + expect(client.getState()).toBe("disconnected"); + + // Should not reconnect after auth_error + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); + const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(reconnects).toHaveLength(0); + }); +}); + +describe("auth_ok during reconnection logs reconnect info", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("resets reconnectAttempt to 0 after successful reconnect auth_ok", 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: "", + }, + }), + ); + + // First drop + emitTauriEvent("ws-state", "closed"); + await vi.advanceTimersByTimeAsync(1100); // 1s backoff + 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: "", + }, + }), + ); + + // Second drop — if reconnectAttempt was reset, delay is back to 1s not 2s + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + + // At 1s should reconnect (not 2s) + await vi.advanceTimersByTimeAsync(1000); + const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); + expect(calls).toHaveLength(1); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts deleted file mode 100644 index 4930f594..00000000 --- a/Client/tauri-client/tests/unit/ws.test.ts +++ /dev/null @@ -1,3340 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { ConnectionState } from "../../src/lib/ws"; - -// Mock Tauri APIs — vi.hoisted ensures availability when vi.mock runs -const { mockInvoke, mockListen, eventHandlers } = vi.hoisted(() => { - const handlers = new Map void>>(); - return { - mockInvoke: vi.fn(), - mockListen: vi.fn(async (event: string, handler: (e: { payload: unknown }) => void) => { - if (!handlers.has(event)) handlers.set(event, []); - handlers.get(event)!.push(handler); - return () => { - const arr = handlers.get(event); - if (arr) { - const idx = arr.indexOf(handler); - if (idx >= 0) arr.splice(idx, 1); - } - }; - }), - eventHandlers: handlers, - }; -}); - -vi.mock("@tauri-apps/api/core", () => ({ - invoke: mockInvoke, -})); - -vi.mock("@tauri-apps/api/event", () => ({ - listen: mockListen, -})); - -// Mock crypto.randomUUID -vi.stubGlobal("crypto", { - randomUUID: () => "test-uuid-1234", -}); - -// Suppress console output -vi.spyOn(console, "debug").mockImplementation(() => {}); -vi.spyOn(console, "info").mockImplementation(() => {}); -vi.spyOn(console, "warn").mockImplementation(() => {}); -vi.spyOn(console, "error").mockImplementation(() => {}); - -// Import after mocks are set up -import { createWsClient, toConnectionStatus } from "../../src/lib/ws"; -import { addLogListener, type LogEntry } from "../../src/lib/logger"; - -/** Simulate Tauri emitting an event to JS */ -function emitTauriEvent(event: string, payload: unknown): void { - const handlers = eventHandlers.get(event); - if (handlers) { - for (const h of handlers) { - h({ payload }); - } - } -} - -describe("WebSocket Client (Tauri proxy)", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("starts in disconnected state", () => { - expect(client.getState()).toBe("disconnected"); - }); - - it("transitions to connecting on connect", async () => { - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - client.connect({ host: "localhost:8443", token: "test-token" }); - await vi.advanceTimersByTimeAsync(10); - expect(states).toContain("connecting"); - }); - - it("calls ws_connect with correct URL", async () => { - client.connect({ host: "localhost:8443", token: "test-token" }); - await vi.advanceTimersByTimeAsync(10); - expect(mockInvoke).toHaveBeenCalledWith("ws_connect", { - url: "wss://localhost:8443/api/v1/ws", - }); - }); - - it("sends auth message when Rust reports open", async () => { - client.connect({ host: "localhost:8443", token: "test-token" }); - await vi.advanceTimersByTimeAsync(10); - - // Simulate Rust reporting connection open - emitTauriEvent("ws-state", "open"); - - // Should call ws_send with auth message - expect(mockInvoke).toHaveBeenCalledWith( - "ws_send", - expect.objectContaining({ - message: expect.stringContaining('"type":"auth"'), - }), - ); - }); - - it("transitions to connected on auth_ok", async () => { - client.connect({ host: "localhost:8443", token: "test-token" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "alex", avatar: null, role: "admin" }, - server_name: "Test", - motd: "Hello", - }, - }), - ); - - expect(states).toContain("connected"); - }); - - it("dispatches messages to typed listeners", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (payload) => messages.push(payload)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 5, - user: { id: 1, username: "alex", avatar: null }, - content: "Hello", - reply_to: null, - attachments: [], - timestamp: "2026-03-14T10:00:00Z", - }, - }), - ); - - expect(messages).toHaveLength(1); - }); - - it("unsubscribe removes listener", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - const unsub = client.on("chat_message", (payload) => messages.push(payload)); - unsub(); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 5, - user: { id: 1, username: "alex", avatar: null }, - content: "Hello", - reply_to: null, - attachments: [], - timestamp: "2026-03-14T10:00:00Z", - }, - }), - ); - - expect(messages).toHaveLength(0); - }); - - it("auth_error does NOT trigger reconnect", async () => { - client.connect({ host: "localhost:8443", token: "bad-token" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const authErrors: unknown[] = []; - client.on("auth_error", (payload) => authErrors.push(payload)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_error", - payload: { message: "Invalid token" }, - }), - ); - - await vi.advanceTimersByTimeAsync(60_000); - - expect(authErrors).toHaveLength(1); - expect(client.getState()).toBe("disconnected"); - }); - - it("reconnects on unexpected close with backoff", 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: "", - }, - }), - ); - - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - // Simulate connection closed by Rust proxy - emitTauriEvent("ws-state", "closed"); - - expect(states).toContain("reconnecting"); - - // After 1s backoff, should call ws_connect again - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); - }); - - it("send returns correlation ID", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const id = client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - - expect(id).toBe("test-uuid-1234"); - }); - - it("drops oversized messages", async () => { - client.connect({ - host: "localhost:8443", - token: "t", - maxMessageSizeBytes: 50, - }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - const bigData = JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "x".repeat(100), - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }); - - emitTauriEvent("ws-message", bigData); - expect(messages).toHaveLength(0); - }); - - it("drops malformed JSON", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent("ws-message", "not-json{{{"); - expect(messages).toHaveLength(0); - }); - - it("does not log raw frame content on parse failure (no plaintext leak)", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const entries: LogEntry[] = []; - const remove = addLogListener((e) => entries.push(e)); - const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9"; - emitTauriEvent("ws-message", secret + " not-json{{{"); - remove(); - - // The decrypted frame must never reach the (on-disk-persisted) log... - expect(JSON.stringify(entries)).not.toContain(secret); - // ...but the parse failure is still recorded so it stays debuggable. - expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true); - }); - - it("disconnect prevents reconnect", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - client.disconnect(); - - await vi.advanceTimersByTimeAsync(60_000); - expect(client.getState()).toBe("disconnected"); - }); -}); - -describe("lastSeq tracking", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("should start with lastSeq = 0", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // When open fires, auth message should contain last_seq: 0 - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - expect(authCall).toBeDefined(); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(0); - }); - - it("should update lastSeq from seq field in incoming messages", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Send auth_ok so we're connected - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Send a message with seq 42 - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 42, - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hi", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Now simulate a disconnect + reconnect to verify lastSeq was updated - emitTauriEvent("ws-state", "closed"); - - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); // backoff - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - expect(authCall).toBeDefined(); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(42); - }); - - it("should send last_seq in auth message on reconnect", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Disconnect unexpectedly - emitTauriEvent("ws-state", "closed"); - - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - expect(authCall).toBeDefined(); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(5); - }); - - it("should preserve lastSeq across auto-reconnects", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 10, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // First auto-reconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - // Receive more messages with higher seq - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 11, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 25, - payload: { - id: 2, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hello", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Second auto-reconnect - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(2100); // 2nd attempt = 2s backoff - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(25); - }); - - it("should reset lastSeq to 0 on intentional 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", - seq: 50, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Intentional disconnect (e.g. logout) - client.disconnect(); - - // Reconnect fresh - mockInvoke.mockClear(); - client.connect({ host: "localhost:8443", token: "t2" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - expect(authCall).toBeDefined(); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(0); - }); -}); - -describe("cert mismatch blocking", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("should block reconnect when cert mismatch detected", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Cert mismatch event fires - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - message: "Stored: sha256:OLD", - }); - - expect(client.getState()).toBe("disconnected"); - - // Connection closes after mismatch - emitTauriEvent("ws-state", "closed"); - - // Wait well beyond normal backoff — should NOT reconnect - 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); - 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: "", - }, - }), - ); - - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - message: "Stored: sha256:OLD", - }); - - expect(client.getState()).toBe("disconnected"); - - // Accept the new fingerprint - await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); - - // Now a manual reconnect should work - mockInvoke.mockClear(); - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); - }); - - it("routes first_use cert events to onCertFirstUse, not onCertMismatch (F4/F8)", async () => { - const firstUse: unknown[] = []; - const mismatch: unknown[] = []; - client.onCertFirstUse((e) => firstUse.push(e)); - client.onCertMismatch((e) => mismatch.push(e)); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "first_use", - }); - - expect(firstUse).toHaveLength(1); - expect(mismatch).toHaveLength(0); - }); - - it("startCertListener catches cert events before any WS connect (connect-page path)", async () => { - const firstUse: unknown[] = []; - client.onCertFirstUse((e) => firstUse.push(e)); - - // No connect() — main.ts registers the listener at bootstrap so first-use - // fires during the connect page's health check, before login. - await client.startCertListener(); - - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "first_use", - }); - - expect(firstUse).toHaveLength(1); - }); - - it("should not schedule reconnect when certMismatchBlock is true", 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", - seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Trigger mismatch - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:CHANGED", - status: "mismatch", - message: "Stored: sha256:ORIGINAL", - }); - - expect(mismatchEvents).toHaveLength(1); - - // Connection drops - emitTauriEvent("ws-state", "closed"); - - // State should remain disconnected, not reconnecting - expect(client.getState()).toBe("disconnected"); - - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(60_000); - - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects).toHaveLength(0); - }); -}); - -describe("message handling edge cases", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("silently ignores pong messages", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - // pong has no payload listeners, but we verify no crash - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent("ws-message", JSON.stringify({ type: "pong" })); - expect(messages).toHaveLength(0); - }); - - it("drops messages with missing type", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent("ws-message", JSON.stringify({ payload: { data: "no type" } })); - expect(messages).toHaveLength(0); - }); - - it("drops messages with undefined payload", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent("ws-message", JSON.stringify({ type: "chat_message" })); - expect(messages).toHaveLength(0); - }); - - it("tracks highest seq number (ignores lower seq)", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 10, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // seq=50 then seq=30 — should keep 50 - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 50, - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hi", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 30, - payload: { - id: 2, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hello", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Disconnect and reconnect to verify lastSeq - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(50); - }); - - it("handles message without seq field (defaults to 0)", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - // no seq field - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "no seq", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - expect(messages).toHaveLength(1); - }); - - it("dispatch logs when no listeners for message type", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Send a message with no listener registered — should log "no listeners" - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // No crash means the "no listeners" debug log path executed - expect(client.getState()).toBe("connected"); - }); - - it("dispatch catches listener errors", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Register a listener that throws - client.on("chat_message", () => { - throw new Error("listener boom"); - }); - - // Also register a second listener to verify it still runs - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Second listener should still receive the message - expect(messages).toHaveLength(1); - }); - - it("state listener errors are caught", async () => { - client.onStateChange(() => { - throw new Error("state listener boom"); - }); - - // Should not crash - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - expect(client.getState()).toBe("connecting"); - }); - - it("ws-error event is logged without crash", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // Emit a ws-error event - emitTauriEvent("ws-error", "Connection reset by peer"); - - // No crash expected - expect(client.getState()).toBe("connecting"); - }); - - it("isReplaying returns false when not reconnecting", () => { - expect(client.isReplaying()).toBe(false); - }); - - it("_getWs returns null", () => { - expect(client._getWs()).toBeNull(); - }); - - it("onStateChange unsubscribe works", async () => { - const states: ConnectionState[] = []; - const unsub = client.onStateChange((s) => states.push(s)); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - expect(states.length).toBeGreaterThan(0); - - const count = states.length; - unsub(); - - emitTauriEvent("ws-state", "open"); - expect(states.length).toBe(count); - }); - - it("onCertMismatch unsubscribe works", async () => { - const events: unknown[] = []; - const unsub = client.onCertMismatch((evt) => events.push(evt)); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - unsub(); - - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - message: "Stored: sha256:OLD", - }); - - expect(events).toHaveLength(0); - }); -}); - -describe("reconnection dedup", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("deduplicates messages during reconnection replay", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Auth and get some messages to advance lastSeq - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 5, - id: "msg-5", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Disconnect unexpectedly - emitTauriEvent("ws-state", "closed"); - - // Wait for reconnect - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - // During reconnect, replay dedup is active - expect(client.isReplaying()).toBe(true); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - // Send a message during replay -- first occurrence passes - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 5, - id: "msg-5", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Send the SAME message ID again — should be deduped - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 5, - id: "msg-5", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Only the first occurrence should pass through - expect(messages).toHaveLength(1); - expect((messages[0] as { content: string }).content).toBe("original"); - }); - - it("auth_ok and ready messages are not deduped during replay", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Disconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - expect(client.isReplaying()).toBe(true); - - const authPayloads: unknown[] = []; - client.on("auth_ok", (p) => authPayloads.push(p)); - - // auth_ok during replay should NOT be deduped - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 6, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - expect(authPayloads).toHaveLength(1); - // After auth_ok, replay dedup should be cleared - expect(client.isReplaying()).toBe(false); - }); - - it("dedup uses type:seq as key when message has no id", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "presence", - seq: 10, - payload: { user_id: 1, status: "idle" }, - }), - ); - - // Disconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - const presences: unknown[] = []; - client.on("presence", (p) => presences.push(p)); - - // First presence during replay — passes through - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "presence", - seq: 10, - payload: { user_id: 1, status: "idle" }, - }), - ); - - // Same type:seq — should be deduped - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "presence", - seq: 10, - payload: { user_id: 1, status: "idle" }, - }), - ); - - // Different seq — should pass through - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "presence", - seq: 11, - payload: { user_id: 1, status: "online" }, - }), - ); - - expect(presences).toHaveLength(2); - expect((presences[0] as { status: string }).status).toBe("idle"); - expect((presences[1] as { status: string }).status).toBe("online"); - }); - - it("dedup is not active for first connection (lastSeq=0)", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // First connect should NOT enable dedup - expect(client.isReplaying()).toBe(false); - }); -}); - -describe("heartbeat", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("sends heartbeat ping every 30 seconds after auth_ok", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - mockInvoke.mockClear(); - - // Advance 30 seconds — should send a ping - await vi.advanceTimersByTimeAsync(30_000); - - const pingSends = mockInvoke.mock.calls.filter( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"ping"'), - ); - expect(pingSends.length).toBeGreaterThanOrEqual(1); - }); - - it("stops heartbeat on 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", - seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - client.disconnect(); - mockInvoke.mockClear(); - - // No heartbeat should be sent after disconnect - await vi.advanceTimersByTimeAsync(60_000); - - const pingSends = mockInvoke.mock.calls.filter( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"ping"'), - ); - expect(pingSends).toHaveLength(0); - }); -}); - -describe("parseStoredFingerprint", () => { - // Import the pure function directly - let parseStoredFingerprint: typeof import("../../src/lib/ws").parseStoredFingerprint; - - beforeEach(async () => { - const mod = await import("../../src/lib/ws"); - parseStoredFingerprint = mod.parseStoredFingerprint; - }); - - it("returns undefined for undefined input", () => { - expect(parseStoredFingerprint(undefined)).toBeUndefined(); - }); - - it("returns undefined for empty string", () => { - expect(parseStoredFingerprint("")).toBeUndefined(); - }); - - it("returns undefined when no Stored: prefix found", () => { - expect(parseStoredFingerprint("no match here")).toBeUndefined(); - }); - - it("extracts fingerprint after Stored: prefix", () => { - expect(parseStoredFingerprint("Stored: sha256:ABCDEF")).toBe("sha256:ABCDEF"); - }); - - it("extracts first non-whitespace token after Stored:", () => { - expect(parseStoredFingerprint("Stored: sha256:XYZ trailing")).toBe("sha256:XYZ"); - }); - - it("extracts fingerprint from longer message string", () => { - expect(parseStoredFingerprint("Certificate mismatch. Stored: sha256:OLD123")).toBe( - "sha256:OLD123", - ); - }); -}); - -describe("setState deduplication", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("does not notify listeners when state is already the same", async () => { - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // State is now "connecting". Count how many times "connecting" appeared. - const connectingCount = states.filter((s) => s === "connecting").length; - expect(connectingCount).toBe(1); - }); - - it("notifies listeners when state actually changes", async () => { - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - 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: "", - }, - }), - ); - - // Should have transitioned: connecting -> authenticating -> connected - expect(states).toContain("connecting"); - expect(states).toContain("authenticating"); - expect(states).toContain("connected"); - }); -}); - -describe("getReconnectDelay boundary and arithmetic", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("first reconnect delay is 1000ms (1000 * 2^0)", 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("ws-state", "closed"); - mockInvoke.mockClear(); - - // At 999ms, should NOT have reconnected yet - await vi.advanceTimersByTimeAsync(999); - const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(callsBefore).toHaveLength(0); - - // At 1000ms total, should reconnect - await vi.advanceTimersByTimeAsync(1); - const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(callsAfter).toHaveLength(1); - }); - - it("second reconnect delay is 2000ms (1000 * 2^1)", 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: "", - }, - }), - ); - - // First drop + reconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - // Don't send auth_ok, so reconnectAttempt stays incremented - // Simulate another close immediately - emitTauriEvent("ws-state", "closed"); - - mockInvoke.mockClear(); - - // Second attempt should have 2000ms delay - await vi.advanceTimersByTimeAsync(1999); - const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(callsBefore).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1); - const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(callsAfter).toHaveLength(1); - }); - - it("delay uses default 30000ms cap when maxReconnectDelayMs not set", 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: "", - }, - }), - ); - - // Simulate many drops to ramp up backoff - for (let i = 0; i < 10; i++) { - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(31_000); - } - - // After 10 attempts, uncapped delay would be 1000*2^10 = 1024000ms - // But it should be capped at 30000ms (default) - mockInvoke.mockClear(); - emitTauriEvent("ws-state", "closed"); - - // Should reconnect within 30s (capped), not 1024s - await vi.advanceTimersByTimeAsync(30_001); - const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(calls.length).toBeGreaterThanOrEqual(1); - }); -}); - -describe("handleMessage size boundary", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("accepts message exactly at size limit", async () => { - const limit = 200; - client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - const msg = { - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }; - const json = JSON.stringify(msg); - // Pad content to make JSON exactly at limit - const padding = limit - json.length; - if (padding > 0) { - msg.payload.content = "x".repeat(padding); - } - const exactJson = JSON.stringify(msg); - // Ensure it is exactly at limit (not over) - expect(exactJson.length).toBeLessThanOrEqual(limit); - - emitTauriEvent("ws-message", exactJson); - expect(messages.length).toBeGreaterThanOrEqual(0); // should not crash - }); - - it("drops message one byte over size limit", async () => { - const limit = 100; - client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - const msg = { - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "x".repeat(limit), // guarantees over limit - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }; - - emitTauriEvent("ws-message", JSON.stringify(msg)); - expect(messages).toHaveLength(0); - }); - - it("uses default 1MB limit when maxMessageSizeBytes not configured", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - // Message under 1MB should pass - const smallMsg = JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "small", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }); - emitTauriEvent("ws-message", smallMsg); - expect(messages).toHaveLength(1); - }); -}); - -describe("seq tracking boundary conditions", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("does NOT update lastSeq when seq equals current lastSeq (> not >=)", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 10, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Send message with same seq=10 — should NOT change lastSeq - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 10, - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "same seq", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Verify lastSeq is still 10 via reconnect auth message - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(10); - }); - - it("treats non-number seq as 0", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Send message with string seq — treated as 0, should not reduce lastSeq - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: "not-a-number", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "bad seq", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - const authCall = mockInvoke.mock.calls.find( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"auth"'), - ); - const authMsg = JSON.parse((authCall![1] as { message: string }).message); - expect(authMsg.payload.last_seq).toBe(5); - }); -}); - -describe("scheduleReconnect guard clauses", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("does not reconnect when intentionalClose is true (disconnect called)", 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: "", - }, - }), - ); - - // Intentional disconnect sets intentionalClose=true - client.disconnect(); - mockInvoke.mockClear(); - - await vi.advanceTimersByTimeAsync(60_000); - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects).toHaveLength(0); - expect(client.getState()).toBe("disconnected"); - }); - - it("does not reconnect when certMismatchBlock is true", 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: "", - }, - }), - ); - - // Trigger cert mismatch - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - }); - - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - - await vi.advanceTimersByTimeAsync(60_000); - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects).toHaveLength(0); - }); - - it("reconnect timer callback bails out safely when config is cleared", 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: "", - }, - }), - ); - - // Unexpected close schedules reconnect. - emitTauriEvent("ws-state", "closed"); - - // Simulate config being cleared before timer callback executes. - client.disconnect(); - mockInvoke.mockClear(); - - await vi.advanceTimersByTimeAsync(2_000); - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects).toHaveLength(0); - expect(client.getState()).toBe("disconnected"); - }); -}); - -describe("cert-tofu non-mismatch statuses", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("trusted_first_use status does not block reconnect", 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: "", - }, - }), - ); - - // Non-mismatch cert event - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:FIRST", - status: "trusted_first_use", - }); - - // State should still be connected (not disconnected) - expect(client.getState()).toBe("connected"); - - // Verify mismatch listener was NOT called - const mismatchEvents: unknown[] = []; - client.onCertMismatch((e) => mismatchEvents.push(e)); - - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:TRUSTED", - status: "trusted", - }); - - expect(mismatchEvents).toHaveLength(0); - expect(client.getState()).toBe("connected"); - }); -}); - -describe("dedup eviction when exceeding MAX_DEDUP_SIZE", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("evicts oldest entry when dedup set exceeds 1000 entries", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Get past lastSeq > 0 condition - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 100, - payload: { - id: 99, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "bump seq", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Disconnect to trigger dedup mode - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - expect(client.isReplaying()).toBe(true); - - const messages: unknown[] = []; - client.on("chat_message", (p) => messages.push(p)); - - // Send 1002 unique messages to trigger eviction (MAX_DEDUP_SIZE = 1000) - for (let i = 0; i < 1002; i++) { - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 101 + i, - id: `msg-${i}`, - payload: { - id: i, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: `msg ${i}`, - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - } - - // All 1002 should have been dispatched (first occurrence of each) - expect(messages).toHaveLength(1002); - - // Now re-send the very first message (msg-0) — it was evicted, so it should pass again - const countBefore = messages.length; - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 101, - id: "msg-0", - payload: { - id: 0, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "msg 0", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - expect(messages).toHaveLength(countBefore + 1); - }); -}); - -describe("auth_error during reconnection replay", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("auth_error is not deduped during replay and stops reconnect", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Disconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - expect(client.isReplaying()).toBe(true); - - const errors: unknown[] = []; - client.on("auth_error", (p) => errors.push(p)); - - // auth_error during replay — should NOT be deduped - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_error", - payload: { message: "Token expired" }, - }), - ); - - expect(errors).toHaveLength(1); - expect(client.getState()).toBe("disconnected"); - - // Should not reconnect after auth_error - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(60_000); - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects).toHaveLength(0); - }); -}); - -describe("wsGeneration stale listener guard", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("ignores events from stale generation after new connect()", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // Capture the handlers registered in the first connect - const oldMsgHandlers = [...(eventHandlers.get("ws-message") ?? [])]; - const oldStateHandlers = [...(eventHandlers.get("ws-state") ?? [])]; - - // Start a new connection (increments wsGeneration, cleans up old handlers) - client.connect({ host: "localhost:8443", token: "t2" }); - await vi.advanceTimersByTimeAsync(10); - - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - // If any old handlers survived cleanup, calling them should be a no-op - // because gen !== wsGeneration - for (const h of oldMsgHandlers) { - h({ - payload: JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - }); - } - - for (const h of oldStateHandlers) { - h({ payload: "open" }); - } - - // State should NOT have changed to connected from stale handlers - expect(states).not.toContain("connected"); - }); -}); - -describe("acceptCertFingerprint edge cases", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("calls Tauri invoke with correct command and args", async () => { - // Must connect first so Tauri APIs are loaded - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - await client.acceptCertFingerprint("example.com", "sha256:NEWCERT"); - - expect(mockInvoke).toHaveBeenCalledWith("accept_cert_fingerprint", { - host: "example.com", - fingerprint: "sha256:NEWCERT", - }); - }); - - it("clears certMismatchBlock so reconnect works again", 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: "", - }, - }), - ); - - // Block with mismatch - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - }); - expect(client.getState()).toBe("disconnected"); - - // Accept fingerprint - await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); - - // Reconnect should now work - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - expect(client.getState()).toBe("connecting"); - }); -}); - -describe("heartbeat proxyOpen guard", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("does not send ping when proxyOpen is false (connection dropped mid-heartbeat)", 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: "", - }, - }), - ); - - // Heartbeat started. Now close the proxy (sets proxyOpen=false) - emitTauriEvent("ws-state", "closed"); - - // Clear mocks and advance past heartbeat interval - mockInvoke.mockClear(); - - // The heartbeat was stopped by close handler, so no pings should fire - await vi.advanceTimersByTimeAsync(35_000); - - const pings = mockInvoke.mock.calls.filter( - (c) => - c[0] === "ws_send" && - typeof c[1]?.message === "string" && - (c[1].message as string).includes('"type":"ping"'), - ); - expect(pings).toHaveLength(0); - }); -}); - -describe("disconnect resets certMismatchBlock", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("clears certMismatchBlock on intentional 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: "", - }, - }), - ); - - // Set cert mismatch block - emitTauriEvent("cert-tofu", { - host: "localhost:8443", - fingerprint: "sha256:NEW", - status: "mismatch", - }); - - // Intentional disconnect should clear the block - client.disconnect(); - - // Now reconnect should work (certMismatchBlock was cleared) - mockInvoke.mockClear(); - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); - expect(client.getState()).toBe("connecting"); - }); -}); - -describe("auth_ok during reconnection logs reconnect info", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("resets reconnectAttempt to 0 after successful reconnect auth_ok", 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: "", - }, - }), - ); - - // First drop - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); // 1s backoff - 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: "", - }, - }), - ); - - // Second drop — if reconnectAttempt was reset, delay is back to 1s not 2s - emitTauriEvent("ws-state", "closed"); - mockInvoke.mockClear(); - - // At 1s should reconnect (not 2s) - await vi.advanceTimersByTimeAsync(1000); - const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(calls).toHaveLength(1); - }); -}); - -describe("dispatch with no listeners for type", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("does not crash when dispatching to type with empty listener set", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Register and immediately unregister a listener - const unsub = client.on("chat_message", () => {}); - unsub(); - - // Now dispatch a message to that type — empty set - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // No crash - expect(true).toBe(true); - }); - - it("dispatches message with id to listener", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const receivedIds: (string | undefined)[] = []; - client.on("chat_message", (_payload, id) => { - receivedIds.push(id); - }); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - id: "correlation-123", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - expect(receivedIds).toEqual(["correlation-123"]); - }); -}); - -describe("on() creates Set for new type", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("creates a listener set for a type that has never been registered", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const results: unknown[] = []; - client.on("presence", (p) => results.push(p)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "presence", - payload: { user_id: 1, status: "online" }, - }), - ); - - expect(results).toHaveLength(1); - }); - - it("multiple listeners on same type all receive messages", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const results1: unknown[] = []; - const results2: unknown[] = []; - client.on("typing", (p) => results1.push(p)); - client.on("typing", (p) => results2.push(p)); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "typing", - payload: { channel_id: 1, user_id: 1, username: "a" }, - }), - ); - - expect(results1).toHaveLength(1); - expect(results2).toHaveLength(1); - }); -}); - -describe("send envelope format", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("wraps message with id and serializes to JSON", 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: "", - }, - }), - ); - - mockInvoke.mockClear(); - - client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hello", reply_to: null, attachments: [] }, - }); - - const sendCall = mockInvoke.mock.calls.find((c) => c[0] === "ws_send"); - expect(sendCall).toBeDefined(); - - const sent = JSON.parse((sendCall![1] as { message: string }).message); - expect(sent.type).toBe("chat_send"); - expect(sent.id).toBe("test-uuid-1234"); - expect(sent.payload.channel_id).toBe(1); - expect(sent.payload.content).toBe("hello"); - expect(sent.payload.reply_to).toBeNull(); - expect(sent.payload.attachments).toEqual([]); - }); -}); - -describe("connect when Tauri APIs unavailable", () => { - it("falls back to disconnected when ensureTauriApis fails", async () => { - vi.useFakeTimers(); - - // Create a fresh client that will try to load Tauri APIs fresh - // The mock is already set up to resolve, so we need to simulate unavailability - // by making tauriInvoke null after ensureTauriApis - const origInvoke = mockInvoke; - - // Temporarily clear the mock module to simulate Tauri not available - // We test this indirectly: if ws_connect is never called but state - // goes back to disconnected, the guard worked - const client2 = createWsClient(); - const states: ConnectionState[] = []; - client2.onStateChange((s) => states.push(s)); - - client2.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // With the mock in place, it should proceed normally - expect(states).toContain("connecting"); - - client2.disconnect(); - vi.useRealTimers(); - }); -}); - -describe("cleanupEventListeners edge cases", () => { - let client: ReturnType; - // Save original mockListen implementation to restore after override tests - let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R - ? R - : never; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - originalMockListenImpl = mockListen.getMockImplementation()!; - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - // Restore the original mockListen implementation so later tests work - mockListen.mockImplementation(originalMockListenImpl!); - vi.useRealTimers(); - }); - - it("handles unsub functions that return rejected promises", async () => { - // Override mockListen to return an unsub that returns a rejected promise - mockListen.mockImplementation( - async (_event: string, _handler: (e: { payload: unknown }) => void) => { - return () => { - return Promise.reject(new Error("resource invalidated")); - }; - }, - ); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // Disconnect triggers cleanupEventListeners — should not crash - client.disconnect(); - await vi.advanceTimersByTimeAsync(10); - - expect(client.getState()).toBe("disconnected"); - }); - - it("handles unsub functions that throw synchronously", async () => { - mockListen.mockImplementation( - async (_event: string, _handler: (e: { payload: unknown }) => void) => { - return () => { - throw new Error("sync unsub error"); - }; - }, - ); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // Should not crash - client.disconnect(); - expect(client.getState()).toBe("disconnected"); - }); -}); - -describe("dedup does not filter auth_ok, auth_error, or ready during replay", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("ready message is not deduped during replay", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - seq: 10, - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hi", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Disconnect and reconnect - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - expect(client.isReplaying()).toBe(true); - - const readyPayloads: unknown[] = []; - client.on("ready", (p) => readyPayloads.push(p)); - - // Send ready during replay BEFORE auth_ok — should NOT be deduped - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "ready", - seq: 11, - payload: { - channels: [], - members: [], - voice_states: [], - roles: [], - }, - }), - ); - - expect(readyPayloads).toHaveLength(1); - - // Send ready again with same seq — ready is exempt from dedup, so it passes - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "ready", - seq: 11, - payload: { - channels: [], - members: [], - voice_states: [], - roles: [], - }, - }), - ); - - expect(readyPayloads).toHaveLength(2); - }); -}); - -describe("send edge cases", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("send when not connected does not crash (logs warning)", () => { - // Client is disconnected — send should warn but not crash - const id = client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - - expect(id).toBe("test-uuid-1234"); - }); - - it("ws_connect failure triggers reconnect", async () => { - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_connect") throw new Error("connection refused"); - return undefined; - }); - - const states: ConnectionState[] = []; - client.onStateChange((s) => states.push(s)); - - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - - // Should attempt reconnect after failure - expect(states).toContain("reconnecting"); - }); - - it("reconnect with successful auth_ok resets reconnect attempt counter", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Drop connection - emitTauriEvent("ws-state", "closed"); - - // First reconnect (1s backoff) - await vi.advanceTimersByTimeAsync(1100); - emitTauriEvent("ws-state", "open"); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: 2, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - - // Drop again - emitTauriEvent("ws-state", "closed"); - - // If reconnect counter was reset, delay should be back to 1s (not 2s) - mockInvoke.mockClear(); - await vi.advanceTimersByTimeAsync(1100); - - const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); - expect(reconnects.length).toBeGreaterThanOrEqual(1); - }); - - it("ws_send rejection is caught without crash", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Make ws_send reject - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_send") throw new Error("send failed"); - return undefined; - }); - - // Send should not crash despite ws_send rejection - client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - - // Flush promise to trigger the catch - await vi.advanceTimersByTimeAsync(10); - expect(client.getState()).toBe("connected"); - }); - - it("onSendFailure fires with NETWORK when ws_send hits backpressure (channel full)", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); - return undefined; - }); - - const failures: Array<{ id: string; code: string }> = []; - client.onSendFailure((id, code) => failures.push({ id, code })); - - const id = client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - await vi.advanceTimersByTimeAsync(10); - - expect(failures).toEqual([{ id, code: "NETWORK" }]); - }); - - it("onSendFailure fires with OFFLINE when ws_send reports the channel closed", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_send") throw new Error("ws_send: channel closed"); - return undefined; - }); - - const failures: Array<{ id: string; code: string }> = []; - client.onSendFailure((id, code) => failures.push({ id, code })); - - const id = client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - await vi.advanceTimersByTimeAsync(10); - - expect(failures).toEqual([{ id, code: "OFFLINE" }]); - }); - - it("onSendFailure fires with OFFLINE when sending while the proxy is not open", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Drop the proxy: subsequent sends take the not-open early return. - emitTauriEvent("ws-state", "closed"); - - const failures: Array<{ id: string; code: string }> = []; - client.onSendFailure((id, code) => failures.push({ id, code })); - - const id = client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - // The early-return notification is deferred a microtask so callers can - // register the id (optimistic row) before the failure lands. - expect(failures).toEqual([]); - await vi.advanceTimersByTimeAsync(0); - - expect(failures).toEqual([{ id, code: "OFFLINE" }]); - }); - - it("heartbeat ping failures do not fire onSendFailure (no envelope id)", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); - return undefined; - }); - - const failures: Array<{ id: string; code: string }> = []; - client.onSendFailure((id, code) => failures.push({ id, code })); - - // Let the 30s heartbeat fire (and its ws_send reject). - await vi.advanceTimersByTimeAsync(30_100); - - expect(failures).toEqual([]); - }); - - it("onSendFailure unsubscribe works", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); - return undefined; - }); - - const failures: Array<{ id: string; code: string }> = []; - const unsub = client.onSendFailure((id, code) => failures.push({ id, code })); - unsub(); - - client.send({ - type: "chat_send", - payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, - }); - await vi.advanceTimersByTimeAsync(10); - - expect(failures).toEqual([]); - }); - - it("ws_disconnect error is ignored during disconnectProxy", async () => { - client.connect({ host: "localhost:8443", 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: "", - }, - }), - ); - - // Make ws_disconnect throw - mockInvoke.mockImplementation(async (cmd: string) => { - if (cmd === "ws_disconnect") throw new Error("disconnect failed"); - return undefined; - }); - - // Disconnect should not crash - client.disconnect(); - await vi.advanceTimersByTimeAsync(10); - expect(client.getState()).toBe("disconnected"); - }); - - it("reconnect delay is capped by maxReconnectDelayMs", async () => { - client.connect({ - host: "localhost:8443", - token: "t", - maxReconnectDelayMs: 5000, - }); - 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: "", - }, - }), - ); - - // Force multiple reconnect attempts to ramp up backoff - for (let i = 0; i < 5; i++) { - emitTauriEvent("ws-state", "closed"); - await vi.advanceTimersByTimeAsync(10_000); // well past any backoff - emitTauriEvent("ws-state", "open"); - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "auth_ok", - seq: i + 2, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", - motd: "", - }, - }), - ); - } - - // At this point, the reconnect delay should be capped at 5000ms - // The fact that the loop completed without hanging proves capping works - expect(client.getState()).toBe("connected"); - }); -}); - -// --------------------------------------------------------------------------- -// Listener registry mechanics (no Tauri connection needed) -// --------------------------------------------------------------------------- - -describe("listener registry mechanics (on/off/dispatch)", () => { - let client: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - mockInvoke.mockReset(); - mockInvoke.mockResolvedValue(undefined); - mockListen.mockClear(); - eventHandlers.clear(); - client = createWsClient(); - }); - - afterEach(() => { - client.disconnect(); - vi.useRealTimers(); - }); - - it("on() registers a listener and returns an unsubscribe function", () => { - const listener = vi.fn(); - const unsub = client.on("chat_message", listener); - expect(typeof unsub).toBe("function"); - }); - - it("off via returned unsubscribe removes a specific listener", async () => { - // Connect so we can dispatch messages through the proxy - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const calls: string[] = []; - const listenerA = () => calls.push("A"); - const listenerB = () => calls.push("B"); - - client.on("chat_message", listenerA); - const unsubB = client.on("chat_message", listenerB); - - // Remove only B - unsubB(); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - expect(calls).toEqual(["A"]); - }); - - it("multiple listeners on the same event type all get called", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const calls: string[] = []; - client.on("chat_message", () => calls.push("first")); - client.on("chat_message", () => calls.push("second")); - client.on("chat_message", () => calls.push("third")); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - expect(calls).toEqual(["first", "second", "third"]); - }); - - it("listener removal mid-dispatch does not crash", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const calls: string[] = []; - let unsubSelf: (() => void) | null = null; - - // This listener unsubscribes itself when called - unsubSelf = client.on("chat_message", () => { - calls.push("self-removing"); - unsubSelf!(); - }); - - // Second listener should still be called - client.on("chat_message", () => calls.push("survivor")); - - const msgJson = JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }); - - // First dispatch — self-removing listener fires then removes itself - emitTauriEvent("ws-message", msgJson); - expect(calls).toContain("self-removing"); - expect(calls).toContain("survivor"); - - // Second dispatch — only survivor should fire - calls.length = 0; - emitTauriEvent("ws-message", msgJson); - expect(calls).toEqual(["survivor"]); - }); - - it("unknown event type dispatch does not throw", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - // Dispatch a completely unknown event type — should not crash - expect(() => { - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "totally_unknown_event", - payload: { foo: "bar" }, - }), - ); - }).not.toThrow(); - }); - - it("error boundary: throwing listener does not prevent next listener from running", async () => { - client.connect({ host: "localhost:8443", token: "t" }); - await vi.advanceTimersByTimeAsync(10); - emitTauriEvent("ws-state", "open"); - - const received: string[] = []; - - client.on("chat_message", () => { - throw new Error("first listener explodes"); - }); - client.on("chat_message", (payload) => { - received.push((payload as { content: string }).content); - }); - client.on("chat_message", () => { - throw new Error("third listener also explodes"); - }); - client.on("chat_message", (payload) => { - received.push("fourth:" + (payload as { content: string }).content); - }); - - emitTauriEvent( - "ws-message", - JSON.stringify({ - type: "chat_message", - payload: { - id: 1, - channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hello", - reply_to: null, - attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - }), - ); - - // Both non-throwing listeners should have received the message - expect(received).toEqual(["hello", "fourth:hello"]); - }); -}); - -describe("toConnectionStatus", () => { - it("maps the internal 5-state machine onto the UX-facing 3-state status", () => { - expect(toConnectionStatus("connected")).toBe("connected"); - expect(toConnectionStatus("disconnected")).toBe("disconnected"); - // Mid-retry states must read as "reconnecting", not "disconnected" — - // a reconnect cycle passes through connecting/authenticating. - expect(toConnectionStatus("reconnecting")).toBe("reconnecting"); - expect(toConnectionStatus("connecting")).toBe("reconnecting"); - expect(toConnectionStatus("authenticating")).toBe("reconnecting"); - }); -}); diff --git a/Client/tauri-client/vite.config.ts b/Client/tauri-client/vite.config.ts index 7e65c900..81bf0676 100644 --- a/Client/tauri-client/vite.config.ts +++ b/Client/tauri-client/vite.config.ts @@ -18,6 +18,14 @@ export default defineConfig({ build: { modulePreload: { polyfill: false }, cssCodeSplit: false, + rollupOptions: { + output: { + manualChunks: { + // Keep the ~1.3 MB LiveKit SDK in its own chunk, out of the entry. + livekit: ["livekit-client"], + }, + }, + }, }, resolve: { alias: { diff --git a/README.md b/README.md index e9bb799f..27d715b7 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ That keeps iteration fast, and it also means behaviour can change quickly betwee 2. Run the server binary: - Windows: `chatserver.exe` - Linux: `./chatserver` -3. Open `https://localhost:8443/admin` and create your Owner account. +3. Open `https://localhost:8443/admin` and complete the setup wizard — it creates your Owner account and configures the server for you (settings are saved to `config.yaml` automatically). 4. Generate invite codes in the admin panel and share them with friends. ### Option B: Docker (Linux server) @@ -84,7 +84,7 @@ The client uses TOFU (Trust On First Use) for self-signed certificates: it promp ## What OwnCord Already Has - Real-time channels and direct messages over WebSocket -- Voice/video channels via LiveKit +- Voice/video channels via LiveKit — the LiveKit server binary is downloaded and managed for you - Invite-only registration and role-based permissions - Web admin panel with logs, backups, and update tooling - File uploads and inline media rendering diff --git a/Server/admin/admin.go b/Server/admin/admin.go index be61d913..71a6af57 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -23,11 +23,11 @@ var staticFiles embed.FS // // /api/* — admin REST API (all require ADMINISTRATOR permission) // /* — embedded static files (SPA; index.html for unknown paths) -func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, opts ...SetupOptions) http.Handler { r := chi.NewRouter() // Admin REST API mounted at /api - r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod)) + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, opts...)) // Static files — serve from the "static" sub-tree of the embedded FS. // The //go:embed static directive in this package embeds as "static/…", diff --git a/Server/admin/api.go b/Server/admin/api.go index 3ef9dbe3..4e0ccd7a 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -15,13 +15,22 @@ import ( // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes // are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit, // except for the setup endpoints which are unauthenticated. -func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { +// +// The optional trailing SetupOptions enables the first-run wizard's +// config.yaml write-back and restart; without it the setup endpoints keep +// their legacy account-only behaviour (the case in most tests). +func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, opts ...SetupOptions) http.Handler { r := chi.NewRouter() + var setupOpts SetupOptions + if len(opts) > 0 { + setupOpts = opts[0] + } + // Setup endpoints — unauthenticated, only functional when no users exist. setupLimiter := auth.NewRateLimiter() - r.Get("/setup/status", handleSetupStatus(database)) - r.Post("/setup", handleSetup(database, setupLimiter, allowedOrigins)) + r.Get("/setup/status", handleSetupStatus(database, setupOpts)) + r.Post("/setup", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts)) // SSE log stream — auth is via a single-use ticket from POST /logs/ticket. // EventSource cannot send Authorization headers, so the client first diff --git a/Server/admin/export_test.go b/Server/admin/export_test.go index a067703c..bb03e862 100644 --- a/Server/admin/export_test.go +++ b/Server/admin/export_test.go @@ -1,5 +1,23 @@ package admin +import "sync/atomic" + // SetBackupBaseDir overrides backupBaseDir so tests can point backup handlers // at a temp dir. Lives here so it stays out of the production binary. func SetBackupBaseDir(dir string) { backupBaseDir = dir } + +// StubRestart replaces the process-restart hook for the duration of a test and +// returns a func reporting whether a restart was requested. Without this the +// restore handler would respawn and os.Exit the test binary. +func StubRestart() (restarted func() bool, restore func()) { + restartMu.Lock() + prev := restartSelf + called := &atomic.Bool{} + restartSelf = func(string) { called.Store(true) } + restartMu.Unlock() + return called.Load, func() { + restartMu.Lock() + restartSelf = prev + restartMu.Unlock() + } +} diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 2a287def..16624d50 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -10,10 +10,22 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" + "syscall" + "github.com/go-chi/chi/v5" "github.com/owncord/server/db" + "github.com/owncord/server/updater" +) + +const ( + // restartGraceDelay lets the HTTP response and the server_restart + // broadcast reach clients before the process goes away. + restartGraceDelay = 2 * time.Second + // shutdownGraceDelay is how long SIGTERM gets before the os.Exit backstop. + shutdownGraceDelay = 10 * time.Second ) // backupBaseDir is the directory for backup files, resolved to an absolute @@ -168,9 +180,20 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { // the restore proceeds regardless of client disconnect (Close/copyFile // below are not ctx-aware), so the safety backup must not be skippable // by a canceled request ctx. - preRestore := filepath.Join("data", "backups", "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db") + // backupBaseDir, not a cwd-relative path: the safety copy has to land in + // the same directory the rest of the backup handlers read and write, or + // a server started from another working directory writes it somewhere + // the operator will never find it. + preRestore := filepath.Join(backupBaseDir, "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db") if err := database.BackupTo(context.WithoutCancel(r.Context()), preRestore); err != nil { - slog.Warn("pre-restore backup failed", "err", err) + // Fail closed. The admin panel promises "a pre-restore backup will + // be created" before an irreversible overwrite; proceeding without + // one takes away the safety net the operator was shown, exactly + // when they need it (restoring the wrong or a corrupt backup). + slog.Error("pre-restore backup failed — aborting restore", "err", err) + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", + "could not create the pre-restore safety backup — restore aborted, database untouched") + return } // Notify clients that the server is restarting. @@ -198,15 +221,68 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { return } - slog.Warn("database file replaced — server must restart to use restored data", "backup", name) + slog.Warn("database file replaced — restarting to load the restored data", "backup", name) writeJSON(w, http.StatusOK, map[string]string{ "message": "database restored — server restarting", "backup": name, }) + + // The database is closed and the file underneath it has been swapped: + // this process can serve nothing more. It used to stop here, leaving a + // live server answering every request against a closed DB while the + // response and the restart broadcast both claimed a restart was + // happening. Respawn for real, the same way applying an update does. + go requestRestart("backup_restore") }) } +// restartSelf is the process-restart hook, swappable in tests (which must not +// respawn or exit the test binary). Guarded because the swap happens on the +// test goroutine while the restore handler reads it from its own. +var ( + restartMu sync.Mutex + restartSelf = restartProcess +) + +// requestRestart invokes the current restart hook. +func requestRestart(reason string) { + restartMu.Lock() + fn := restartSelf + restartMu.Unlock() + fn(reason) +} + +// restartProcess spawns a fresh copy of this server and shuts the current one +// down. Mirrors the update-apply path (update_handlers.go): SIGTERM first so +// main.go's graceful shutdown runs, os.Exit as the backstop. +func restartProcess(reason string) { + // Give the HTTP response and the restart broadcast a moment to flush. + time.Sleep(restartGraceDelay) + + exePath, err := os.Executable() + if err != nil { + slog.Error("restart: cannot determine executable path — manual restart required", + "reason", reason, "error", err) + return + } + if resolved, symErr := filepath.EvalSymlinks(exePath); symErr == nil { + exePath = resolved + } + if err := updater.SpawnDetached(exePath, os.Args[1:]); err != nil { + slog.Error("restart: spawning the replacement process failed — manual restart required", + "reason", reason, "error", err) + return + } + + slog.Info("restart: replacement process spawned, shutting down", "reason", reason) + if p, findErr := os.FindProcess(os.Getpid()); findErr == nil { + _ = p.Signal(syscall.SIGTERM) + time.Sleep(shutdownGraceDelay) + } + os.Exit(0) //nolint:gocritic // backstop if the SIGTERM handler didn't exit +} + // copyFile streams src to dst without loading the entire file into memory. func copyFile(src, dst string) error { in, err := os.Open(src) //nolint:gosec // G703: src is from sanitized backup path diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 4a2cf1c2..0c775577 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -6,7 +6,9 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" + "time" "github.com/owncord/server/admin" "github.com/owncord/server/auth" @@ -271,6 +273,9 @@ func TestHandleRestoreBackup_Success(t *testing.T) { t.Fatalf("WriteFile backup: %v", err) } + restarted, restoreHook := admin.StubRestart() + defer restoreHook() + w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil) if w.Code != http.StatusOK { @@ -287,6 +292,84 @@ func TestHandleRestoreBackup_Success(t *testing.T) { if resp["backup"] != backupName { t.Errorf("backup = %q, want %q", resp["backup"], backupName) } + + // The response and the server_restart broadcast both promise a restart. + // Without one the process keeps serving requests against a closed DB. + deadline := time.Now().Add(2 * time.Second) + for !restarted() && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if !restarted() { + t.Error("restore did not request a process restart") + } + + // The safety copy the panel promises must exist on disk. + entries, err := os.ReadDir(backupDir) + if err != nil { + t.Fatalf("ReadDir backups: %v", err) + } + found := false + for _, e := range entries { + if strings.HasPrefix(e.Name(), "pre_restore_") { + found = true + } + } + if !found { + t.Error("no pre_restore_*.db safety backup was created") + } +} + +// TestHandleRestoreBackup_AbortsWithoutSafetyBackup verifies the restore fails +// closed when the pre-restore backup can't be written: the panel promises that +// safety copy, and overwriting the live database without one is unrecoverable. +func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) { + tmpDir := chdirTemp(t) + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(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) + } + backupName := "chatserver_20240101_120000.db" + dbFile := filepath.Join(tmpDir, "data", "chatserver.db") + if err := os.WriteFile(filepath.Join(backupDir, backupName), []byte("replacement"), 0o644); err != nil { + t.Fatalf("WriteFile backup: %v", err) + } + if err := os.WriteFile(dbFile, []byte("original"), 0o644); err != nil { + t.Fatalf("WriteFile db: %v", err) + } + + restarted, restoreHook := admin.StubRestart() + defer restoreHook() + + // Make the safety copy impossible: VACUUM INTO refuses a destination that + // already exists. The name is pre_restore_.db, so occupy the + // next few seconds' worth of candidates. + admin.SetBackupBaseDir(backupDir) + for i := range 4 { + name := "pre_restore_" + time.Now().UTC().Add(time.Duration(i)*time.Second).Format("20060102_150405") + ".db" + if err := os.WriteFile(filepath.Join(backupDir, name), []byte("occupied"), 0o644); err != nil { + t.Fatalf("WriteFile blocker: %v", err) + } + } + + w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (restore must abort); body: %s", w.Code, w.Body.String()) + } + if restarted() { + t.Error("aborted restore must not restart the process") + } + data, err := os.ReadFile(dbFile) + if err != nil { + t.Fatalf("ReadFile db: %v", err) + } + if string(data) != "original" { + t.Errorf("database was overwritten despite the abort: %q", string(data)) + } } // TestHandleRestoreBackup_NotFound verifies that restoring a missing backup diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index 6d6588c6..9208510f 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -109,36 +109,37 @@ type LogEntry struct { // RingBuffer is a bounded, thread-safe circular buffer of log entries // with fan-out to SSE subscriber channels. +// +// It is a true ring (fixed backing array + write position), modelled on +// ws.EventRingBuffer: overwriting the oldest entry is a single slot store, +// not a fresh capacity-sized allocation + copy per write. type RingBuffer struct { mu syncutil.Mutex - entries []LogEntry - capacity int + entries []LogEntry // fixed backing array, len == capacity + pos int // next write position + count int // entries stored (up to len(entries)) subscribers map[*chan LogEntry]struct{} } -// NewRingBuffer creates a ring buffer with the given capacity. +// NewRingBuffer creates a ring buffer with the given capacity (must be > 0). func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ - entries: make([]LogEntry, 0, capacity), - capacity: capacity, + entries: make([]LogEntry, capacity), subscribers: make(map[*chan LogEntry]struct{}), } } -// Write appends an entry, drops the oldest if full, and fans out +// Write appends an entry, overwriting the oldest if full, and fans out // to all subscribers (non-blocking to avoid slow clients blocking logging). func (rb *RingBuffer) Write(entry LogEntry) { rb.mu.Lock() defer rb.mu.Unlock() - if len(rb.entries) >= rb.capacity { - // Copy to a new slice to release the backing array's first slot, - // preventing unbounded growth from repeated re-slicing. - fresh := make([]LogEntry, rb.capacity-1, rb.capacity) - copy(fresh, rb.entries[1:]) - rb.entries = fresh + rb.entries[rb.pos] = entry + rb.pos = (rb.pos + 1) % len(rb.entries) + if rb.count < len(rb.entries) { + rb.count++ } - rb.entries = append(rb.entries, entry) for chp := range rb.subscribers { select { @@ -149,12 +150,19 @@ func (rb *RingBuffer) Write(entry LogEntry) { } } -// Snapshot returns a copy of all current entries for backfill. +// Snapshot returns a copy of all current entries, oldest first, for backfill. func (rb *RingBuffer) Snapshot() []LogEntry { rb.mu.Lock() defer rb.mu.Unlock() - out := make([]LogEntry, len(rb.entries)) - copy(out, rb.entries) + out := make([]LogEntry, rb.count) + if rb.count < len(rb.entries) { + // Not yet wrapped: entries [0, count) are already in order. + copy(out, rb.entries[:rb.count]) + return out + } + // Wrapped: oldest entry sits at pos. + n := copy(out, rb.entries[rb.pos:]) + copy(out[n:], rb.entries[:rb.pos]) return out } @@ -191,7 +199,9 @@ type ringHandler struct { } // NewMultiHandler creates a handler that sends records to both stdout -// and the ring buffer. The ring buffer captures all levels from minLevel. +// and the ring buffer. The ring buffer captures all levels from minLevel; +// pass a *slog.LevelVar to retune the threshold at runtime. Enabled reports +// false below both thresholds, so gated Debug calls cost nothing. func NewMultiHandler(stdout slog.Handler, buf *RingBuffer, minLevel slog.Leveler) slog.Handler { return &multiHandler{ stdout: stdout, diff --git a/Server/admin/logstream_alloc_test.go b/Server/admin/logstream_alloc_test.go new file mode 100644 index 00000000..a2690956 --- /dev/null +++ b/Server/admin/logstream_alloc_test.go @@ -0,0 +1,23 @@ +//go:build !race && !deadlock + +package admin + +import "testing" + +// TestRingBuffer_WriteDoesNotAllocate locks in the point of the true-ring +// rewrite: a full buffer's Write is a slot overwrite, not a fresh +// capacity-sized slice + copy per log line. Skipped under -race, where the +// detector's instrumentation skews AllocsPerRun, and under the deadlock tag, +// where syncutil.Mutex is the go-deadlock mutex whose Lock allocates. +func TestRingBuffer_WriteDoesNotAllocate(t *testing.T) { + buf := NewRingBuffer(64) + entry := LogEntry{Timestamp: "2026-07-31T00:00:00Z", Level: "INFO", Message: "steady state"} + // Fill past capacity so every measured Write overwrites the oldest slot. + for range 128 { + buf.Write(entry) + } + + if allocs := testing.AllocsPerRun(1000, func() { buf.Write(entry) }); allocs != 0 { + t.Errorf("Write allocates %.1f objects per call at steady state, want 0", allocs) + } +} diff --git a/Server/admin/main_test.go b/Server/admin/main_test.go index 36ed9e19..e745e763 100644 --- a/Server/admin/main_test.go +++ b/Server/admin/main_test.go @@ -4,8 +4,14 @@ import ( "testing" "go.uber.org/goleak" + "golang.org/x/crypto/bcrypt" + + "github.com/owncord/server/auth" ) func TestMain(m *testing.M) { + // Password hashing dominates this suite's runtime at the production cost + // of 12; nothing under test depends on hash strength. + auth.SetCostForTesting(bcrypt.MinCost) goleak.VerifyTestMain(m) } diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index aea97edb..d55c7aca 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -346,7 +346,7 @@ func TestHandleGetSettings_DBError(t *testing.T) { // when the database query fails. func TestHandleSetupStatus_DBError(t *testing.T) { database := openWhiteboxTestDB(t) - handler := handleSetupStatus(database) + handler := handleSetupStatus(database, SetupOptions{}) _ = database.Close() diff --git a/Server/admin/multihandler_test.go b/Server/admin/multihandler_test.go index cce08c86..c3e4bf1d 100644 --- a/Server/admin/multihandler_test.go +++ b/Server/admin/multihandler_test.go @@ -260,6 +260,37 @@ func TestRingBuffer_Subscribe_SlowSubscriberDoesNotBlockWrites(t *testing.T) { } } +// ─── RingBuffer ring semantics ────────────────────────────────────────────── + +func TestRingBuffer_Snapshot_OldestFirstBeforeWrap(t *testing.T) { + buf := NewRingBuffer(4) + buf.Write(LogEntry{Message: "a"}) + buf.Write(LogEntry{Message: "b"}) + + got := buf.Snapshot() + if len(got) != 2 || got[0].Message != "a" || got[1].Message != "b" { + t.Fatalf("Snapshot = %v, want [a b] in write order", got) + } +} + +func TestRingBuffer_Snapshot_OldestFirstAfterWrap(t *testing.T) { + // Capacity 4, six writes: the ring keeps the newest four, oldest first. + buf := NewRingBuffer(4) + for _, m := range []string{"a", "b", "c", "d", "e", "f"} { + buf.Write(LogEntry{Message: m}) + } + + got := buf.Snapshot() + if len(got) != 4 { + t.Fatalf("Snapshot holds %d entries, want capacity 4", len(got)) + } + for i, want := range []string{"c", "d", "e", "f"} { + if got[i].Message != want { + t.Errorf("Snapshot[%d] = %q, want %q (oldest-first after wrap)", i, got[i].Message, want) + } + } +} + // ─── categorizeSource ─────────────────────────────────────────────────────── func TestCategorizeSource_NoPCIsServer(t *testing.T) { diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index 09e3cd5b..0fbb850e 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -13,6 +13,7 @@ import ( "github.com/microcosm-cc/bluemonday" "github.com/owncord/server/auth" + "github.com/owncord/server/config" "github.com/owncord/server/db" ) @@ -25,12 +26,18 @@ const ownerRoleID = 1 // setupStatusResponse is the JSON shape returned by GET /api/setup/status. type setupStatusResponse struct { NeedsSetup bool `json:"needs_setup"` + // Defaults prefills the setup wizard. Present only while setup is needed + // and the server was wired with its running config (see SetupOptions). + Defaults *setupDefaults `json:"defaults,omitempty"` } // setupRequest is the JSON body for POST /api/setup. type setupRequest struct { Username string `json:"username"` Password string `json:"password"` + // Wizard carries the optional first-run configuration. Absent = legacy + // behaviour: create the owner account only. + Wizard *setupWizardRequest `json:"wizard,omitempty"` } // setupResponse is the JSON shape returned on successful setup. @@ -39,23 +46,63 @@ type setupResponse struct { UserID int64 `json:"user_id"` Username string `json:"username"` InviteCode string `json:"invite_code"` + // RestartRequired is true when wizard values that are only read at + // startup differ from the running config; the server restarts itself + // right after this response is sent. + RestartRequired bool `json:"restart_required"` + // RestartURL is where the admin panel will be reachable after the + // restart (scheme/port may have changed). Empty when no restart happens. + RestartURL string `json:"restart_url,omitempty"` + // Warnings lists non-fatal problems (e.g. config.yaml not writable). + // The account exists whenever this response is returned. + Warnings []string `json:"warnings,omitempty"` } // handleSetupStatus returns whether initial setup is needed (no users exist). -func handleSetupStatus(database *db.DB) http.HandlerFunc { +func handleSetupStatus(database *db.DB, opts SetupOptions) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { count, err := database.UserCount(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count") return } - writeJSON(w, http.StatusOK, setupStatusResponse{NeedsSetup: count == 0}) + resp := setupStatusResponse{NeedsSetup: count == 0} + // Prefill defaults are only exposed pre-setup: after the first user + // exists this endpoint reveals nothing about the configuration. + if resp.NeedsSetup && opts.RunningCfg != nil { + cfg := opts.RunningCfg + d := &setupDefaults{ + ServerName: cfg.Server.Name, + Motd: "Welcome!", + Port: cfg.Server.Port, + TLSMode: cfg.TLS.Mode, + TLSDomain: cfg.TLS.Domain, + UploadMaxSizeMB: cfg.Upload.MaxSizeMB, + VoiceQuality: cfg.Voice.Quality, + VoiceAutoDownload: cfg.Voice.AutoDownloadLiveKit, + } + // The settings table is authoritative for the values the app + // reads live; fall back to the config/seed values on error. + if v, err := database.GetSetting(r.Context(), "server_name"); err == nil && v != "" { + d.ServerName = v + } + if v, err := database.GetSetting(r.Context(), "motd"); err == nil { + d.Motd = v + } + if v, err := database.GetSetting(r.Context(), "registration_open"); err == nil { + d.RegistrationOpen = v == "1" || strings.EqualFold(v, "true") + } + resp.Defaults = d + } + writeJSON(w, http.StatusOK, resp) } } -// handleSetup creates the first owner account. It only works when no users -// exist in the database, preventing abuse after initial setup. -func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []string) http.HandlerFunc { +// handleSetup creates the first owner account and, when the request carries +// a wizard payload, applies the chosen settings (DB + config.yaml) and +// restarts the server if startup-only values changed. It only works when no +// users exist in the database, preventing abuse after initial setup. +func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []string, hub HubBroadcaster, opts SetupOptions) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // CSRF protection: reject cross-origin requests (BUG-097). // A request is accepted when it is same-origin, or when its Origin is @@ -103,6 +150,16 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st return } + // Validate the whole wizard payload BEFORE creating the account so a + // bad value rejects the request instead of leaving a half-configured + // server behind an already-created owner. + if req.Wizard != nil { + if err := validateWizard(req.Wizard); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) + return + } + } + // Hash the password. hash, err := auth.HashPassword(req.Password) if err != nil { @@ -153,19 +210,70 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st return } - slog.Info("server setup completed", "owner", req.Username, "user_id", uid) + // Apply the wizard payload. The account exists from here on, so any + // failure downgrades to a warning — never a 5xx that would orphan the + // owner behind an opaque error. + var warnings []string + restartRequired := false + restartURL := "" + if req.Wizard != nil { + if err := applyWizardSettings(r.Context(), database, req.Wizard); err != nil { + slog.Error("setup wizard: saving settings failed", "error", err) + warnings = append(warnings, + "could not save server settings: "+err.Error()+" — adjust them later in the admin panel's Settings page") + } + if opts.ConfigPath != "" { + if err := config.Save(opts.ConfigPath, buildConfigPatch(req.Wizard, opts.RunningCfg)); err != nil { + slog.Error("setup wizard: writing config failed", "path", opts.ConfigPath, "error", err) + warnings = append(warnings, + "could not write "+opts.ConfigPath+": "+err.Error()+" — your account was created; edit the file manually to apply these settings") + } else { + db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "config_write", "server", 0, + "setup wizard wrote "+opts.ConfigPath+" ("+patchedConfigKeys(req.Wizard)+")") + if opts.RunningCfg != nil && wizardChangesRunningConfig(req.Wizard, opts.RunningCfg) { + restartRequired = true + restartURL = computeRestartURL(r.Host, req.Wizard, opts.RunningCfg) + } + } + } + } + + slog.Info("server setup completed", "owner", req.Username, "user_id", uid, "wizard", req.Wizard != nil, "restart", restartRequired) db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "server_setup", "server", 0, "initial setup: owner account created, default channel and invite generated") writeJSON(w, http.StatusCreated, setupResponse{ - Token: token, - UserID: uid, - Username: req.Username, - InviteCode: inviteCode, + Token: token, + UserID: uid, + Username: req.Username, + InviteCode: inviteCode, + RestartRequired: restartRequired, + RestartURL: restartURL, + Warnings: warnings, }) + + // Restart after the response is written so the browser receives the + // token and the reconnect URL. Mirrors handleRestoreBackup / + // handleApplyUpdate: broadcast, then respawn in a goroutine + // (requestRestart sleeps a grace delay before acting). + if restartRequired { + if hub != nil { + hub.BroadcastServerRestart("setup", restartBroadcastDelaySeconds) + } + restartFn := opts.Restart + if restartFn == nil { + restartFn = requestRestart + } + go restartFn("setup_wizard") + } } } +// restartBroadcastDelaySeconds is the countdown clients are told before the +// setup-wizard restart. There are normally no chat clients connected during +// first-run setup, so this is informational. +const restartBroadcastDelaySeconds = 3 + // isSameOrigin reports whether a browser-supplied Origin names this same // server, by comparing its host:port against the request's Host header. // diff --git a/Server/admin/setup_wizard.go b/Server/admin/setup_wizard.go new file mode 100644 index 00000000..ab1010fe --- /dev/null +++ b/Server/admin/setup_wizard.go @@ -0,0 +1,324 @@ +package admin + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// ─── SetupOptions ──────────────────────────────────────────────────────────── + +// SetupOptions wires the first-run setup wizard to the running server. The +// zero value disables everything beyond legacy owner-account creation, which +// keeps every existing NewAdminAPI/NewHandler call site behaving as before. +type SetupOptions struct { + // ConfigPath is the config.yaml path the wizard patches. Empty disables + // config writing entirely. + ConfigPath string + // RunningCfg is the configuration the server booted with. It provides the + // wizard's prefill defaults, the values compared against to decide whether + // a restart is needed, and the generated LiveKit credentials to persist. + // Nil disables prefill and restarting. + RunningCfg *config.Config + // Restart replaces the process-restart hook (tests). Nil = requestRestart. + Restart func(reason string) +} + +// ─── Wizard payload ────────────────────────────────────────────────────────── + +// setupWizardRequest is the optional "wizard" object on POST /api/setup. +// Every field is a pointer: absent means "keep the current/default value". +type setupWizardRequest struct { + // Stored in the settings table (read live, no restart needed). + ServerName *string `json:"server_name"` + Motd *string `json:"motd"` + RegistrationOpen *bool `json:"registration_open"` + + // Stored in config.yaml (consumed at startup — changes need a restart). + Port *int `json:"port"` + TLSMode *string `json:"tls_mode"` + TLSDomain *string `json:"tls_domain"` + UploadMaxSizeMB *int `json:"upload_max_size_mb"` + VoiceQuality *string `json:"voice_quality"` + // VoiceAutoDownload toggles voice.auto_download_livekit — download and + // run livekit-server automatically so voice works with zero setup. + VoiceAutoDownload *bool `json:"voice_auto_download"` +} + +// setupDefaults is the prefill data the wizard shows. Exposed only while +// needs_setup is true, and deliberately free of secrets, filesystem paths and +// network ACLs. +type setupDefaults struct { + ServerName string `json:"server_name"` + Motd string `json:"motd"` + RegistrationOpen bool `json:"registration_open"` + Port int `json:"port"` + TLSMode string `json:"tls_mode"` + TLSDomain string `json:"tls_domain"` + UploadMaxSizeMB int `json:"upload_max_size_mb"` + VoiceQuality string `json:"voice_quality"` + VoiceAutoDownload bool `json:"voice_auto_download"` +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +const ( + maxServerNameLen = 100 + maxMotdLen = 500 + maxUploadSizeMB = 10240 // 10 GiB +) + +var validTLSModes = map[string]struct{}{ + "self_signed": {}, "acme": {}, "manual": {}, "off": {}, +} + +var validVoiceQualities = map[string]struct{}{ + "low": {}, "medium": {}, "high": {}, +} + +// validateWizard checks and normalises the wizard payload in place. It must +// be called BEFORE the owner account is created so a bad payload rejects the +// whole request instead of leaving a half-configured server. +func validateWizard(wr *setupWizardRequest) error { + if wr.ServerName != nil { + name := strings.TrimSpace(setupSanitizer.Sanitize(*wr.ServerName)) + if name == "" { + return fmt.Errorf("server_name cannot be empty") + } + if len(name) > maxServerNameLen { + return fmt.Errorf("server_name must be at most %d characters", maxServerNameLen) + } + *wr.ServerName = name + } + if wr.Motd != nil { + motd := strings.TrimSpace(setupSanitizer.Sanitize(*wr.Motd)) + if len(motd) > maxMotdLen { + return fmt.Errorf("motd must be at most %d characters", maxMotdLen) + } + *wr.Motd = motd + } + if wr.Port != nil && (*wr.Port < 1 || *wr.Port > 65535) { + return fmt.Errorf("port must be between 1 and 65535") + } + if wr.TLSMode != nil { + mode := strings.ToLower(strings.TrimSpace(*wr.TLSMode)) + if _, ok := validTLSModes[mode]; !ok { + return fmt.Errorf("tls_mode must be one of: self_signed, acme, manual, off") + } + *wr.TLSMode = mode + } + if wr.TLSDomain != nil { + domain := strings.ToLower(strings.TrimSpace(*wr.TLSDomain)) + if domain != "" { + if err := validateHostname(domain); err != nil { + return fmt.Errorf("tls_domain: %w", err) + } + } + *wr.TLSDomain = domain + } + if wr.TLSMode != nil && *wr.TLSMode == "acme" && + (wr.TLSDomain == nil || *wr.TLSDomain == "") { + return fmt.Errorf("tls_domain is required when tls_mode is acme") + } + if wr.UploadMaxSizeMB != nil && (*wr.UploadMaxSizeMB < 1 || *wr.UploadMaxSizeMB > maxUploadSizeMB) { + return fmt.Errorf("upload_max_size_mb must be between 1 and %d", maxUploadSizeMB) + } + if wr.VoiceQuality != nil { + q := strings.ToLower(strings.TrimSpace(*wr.VoiceQuality)) + if _, ok := validVoiceQualities[q]; !ok { + return fmt.Errorf("voice_quality must be one of: low, medium, high") + } + *wr.VoiceQuality = q + } + return nil +} + +// validateHostname checks an LDH (letters-digits-hyphen) DNS name suitable +// for ACME issuance: dotted, each label 1-63 chars, no leading/trailing +// hyphen, 253 chars max. Input is expected lowercase. +func validateHostname(h string) error { + if len(h) > 253 { + return fmt.Errorf("hostname too long") + } + labels := strings.Split(h, ".") + if len(labels) < 2 { + return fmt.Errorf("must be a fully qualified domain name (e.g. chat.example.com)") + } + for _, label := range labels { + if label == "" || len(label) > 63 { + return fmt.Errorf("invalid hostname label") + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("hostname labels cannot start or end with a hyphen") + } + for _, c := range label { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + return fmt.Errorf("hostname contains invalid characters") + } + } + } + return nil +} + +// ─── Applying the wizard ───────────────────────────────────────────────────── + +// applyWizardSettings persists the wizard's DB-backed settings atomically. +// server_name, motd and registration_open are read live by the server; +// max_upload_bytes and voice_quality are written so the Settings page shows +// values consistent with what the wizard put in config.yaml. +func applyWizardSettings(ctx context.Context, database *db.DB, wr *setupWizardRequest) error { + updates := map[string]string{} + if wr.ServerName != nil { + updates["server_name"] = *wr.ServerName + } + if wr.Motd != nil { + updates["motd"] = *wr.Motd + } + if wr.RegistrationOpen != nil { + if *wr.RegistrationOpen { + updates["registration_open"] = "1" + } else { + updates["registration_open"] = "0" + } + } + if wr.UploadMaxSizeMB != nil { + updates["max_upload_bytes"] = strconv.Itoa(*wr.UploadMaxSizeMB * 1024 * 1024) + } + if wr.VoiceQuality != nil { + updates["voice_quality"] = *wr.VoiceQuality + } + if len(updates) == 0 { + return nil + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting transaction: %w", err) + } + for key, value := range updates { + if _, txErr := tx.ExecContext(ctx, + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, value, + ); txErr != nil { + _ = tx.Rollback() + return fmt.Errorf("writing setting %s: %w", key, txErr) + } + } + return tx.Commit() +} + +// buildConfigPatch maps the wizard payload onto config.yaml keys. When the +// running config is known, the LiveKit credentials it booted with are included +// so config.Save can persist them if the file has none — stabilising voice +// tokens across restarts (they are otherwise regenerated randomly each boot). +func buildConfigPatch(wr *setupWizardRequest, running *config.Config) config.Patch { + p := config.Patch{ + ServerPort: wr.Port, + ServerName: wr.ServerName, + TLSMode: wr.TLSMode, + TLSDomain: wr.TLSDomain, + UploadMaxSizeMB: wr.UploadMaxSizeMB, + VoiceQuality: wr.VoiceQuality, + VoiceAutoDownload: wr.VoiceAutoDownload, + } + if running != nil { + if key := running.Voice.LiveKitAPIKey; key != "" { + p.VoiceAPIKey = &key + } + if secret := running.Voice.LiveKitAPISecret; secret != "" { + p.VoiceAPISecret = &secret + } + } + return p +} + +// patchedConfigKeys summarises which config.yaml keys a patch touches, for +// the audit log. Secrets are named, never valued. +func patchedConfigKeys(wr *setupWizardRequest) string { + var keys []string + if wr.Port != nil { + keys = append(keys, "server.port") + } + if wr.ServerName != nil { + keys = append(keys, "server.name") + } + if wr.TLSMode != nil { + keys = append(keys, "tls.mode") + } + if wr.TLSDomain != nil { + keys = append(keys, "tls.domain") + } + if wr.UploadMaxSizeMB != nil { + keys = append(keys, "upload.max_size_mb") + } + if wr.VoiceQuality != nil { + keys = append(keys, "voice.quality") + } + if wr.VoiceAutoDownload != nil { + keys = append(keys, "voice.auto_download_livekit") + } + keys = append(keys, "voice credentials (persisted if unset)") + return strings.Join(keys, ", ") +} + +// ─── Restart decision ──────────────────────────────────────────────────────── + +// wizardChangesRunningConfig reports whether the wizard set any startup-only +// value to something different from what this process booted with. Only those +// changes justify a restart; server.name and the persisted voice credentials +// match the running state by construction. +func wizardChangesRunningConfig(wr *setupWizardRequest, running *config.Config) bool { + if wr.Port != nil && *wr.Port != running.Server.Port { + return true + } + if wr.TLSMode != nil && *wr.TLSMode != running.TLS.Mode { + return true + } + if wr.UploadMaxSizeMB != nil && *wr.UploadMaxSizeMB != running.Upload.MaxSizeMB { + return true + } + if wr.VoiceQuality != nil && *wr.VoiceQuality != running.Voice.Quality { + return true + } + if wr.VoiceAutoDownload != nil && *wr.VoiceAutoDownload != running.Voice.AutoDownloadLiveKit { + return true + } + // A domain change only matters when certificates come from ACME. + effMode := running.TLS.Mode + if wr.TLSMode != nil { + effMode = *wr.TLSMode + } + if effMode == "acme" && wr.TLSDomain != nil && *wr.TLSDomain != running.TLS.Domain { + return true + } + return false +} + +// computeRestartURL builds the admin-panel URL the server will be reachable +// at after restarting with the wizard's values. host is the request's Host +// header (the address the user's browser is already using). +func computeRestartURL(host string, wr *setupWizardRequest, running *config.Config) string { + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + effPort := running.Server.Port + if wr.Port != nil { + effPort = *wr.Port + } + effMode := running.TLS.Mode + if wr.TLSMode != nil { + effMode = *wr.TLSMode + } + scheme := "https" + if effMode == "off" { + scheme = "http" + } + return scheme + "://" + net.JoinHostPort(h, strconv.Itoa(effPort)) + "/admin" +} diff --git a/Server/admin/setup_wizard_test.go b/Server/admin/setup_wizard_test.go new file mode 100644 index 00000000..4e20e4cf --- /dev/null +++ b/Server/admin/setup_wizard_test.go @@ -0,0 +1,429 @@ +package admin_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/owncord/server/admin" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// wizardRunningCfg mimics the config a fresh server boots with: file defaults +// plus the runtime-generated LiveKit credentials. +func wizardRunningCfg() *config.Config { + return &config.Config{ + Server: config.ServerConfig{Port: 8443, Name: "OwnCord Server"}, + TLS: config.TLSConfig{Mode: "self_signed"}, + Upload: config.UploadConfig{MaxSizeMB: 100}, + Voice: config.VoiceConfig{ + LiveKitAPIKey: "key-generated123", + LiveKitAPISecret: "generated-secret-0123456789abcdef", + Quality: "medium", + }, + } +} + +// wizardHandler builds the admin API with wizard options and a restart stub +// that signals restarted (buffered) instead of respawning the process. +func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler { + t.Helper() + return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), + admin.SetupOptions{ + ConfigPath: cfgPath, + RunningCfg: wizardRunningCfg(), + Restart: func(reason string) { restarted <- reason }, + }) +} + +func getSetting(t *testing.T, database *db.DB, key string) string { + t.Helper() + v, err := database.GetSetting(context.Background(), key) + if err != nil { + t.Fatalf("GetSetting(%q): %v", key, err) + } + return v +} + +func TestSetupWizard_FullFlow(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{ + "username": "owner", + "password": "SecurePass123!", + "wizard": map[string]any{ + "server_name": "My Cool Server", + "motd": "Welcome friends!", + "registration_open": true, + "port": 9000, + "tls_mode": "off", + "upload_max_size_mb": 250, + "voice_quality": "high", + "voice_auto_download": true, + }, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String()) + } + + var resp struct { + Token string `json:"token"` + InviteCode string `json:"invite_code"` + RestartRequired bool `json:"restart_required"` + RestartURL string `json:"restart_url"` + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" || resp.InviteCode == "" { + t.Error("token/invite_code missing — account creation should be unchanged") + } + if len(resp.Warnings) != 0 { + t.Errorf("warnings = %v, want none", resp.Warnings) + } + if !resp.RestartRequired { + t.Fatal("restart_required = false, want true (port and tls changed)") + } + // httptest requests carry Host "example.com"; tls off → http scheme. + if resp.RestartURL != "http://example.com:9000/admin" { + t.Errorf("restart_url = %q, want %q", resp.RestartURL, "http://example.com:9000/admin") + } + + select { + case reason := <-restarted: + if reason != "setup_wizard" { + t.Errorf("restart reason = %q, want setup_wizard", reason) + } + case <-time.After(5 * time.Second): + t.Fatal("restart hook was never invoked") + } + + // DB settings the app reads live. + if got := getSetting(t, database, "server_name"); got != "My Cool Server" { + t.Errorf("server_name = %q", got) + } + if got := getSetting(t, database, "motd"); got != "Welcome friends!" { + t.Errorf("motd = %q", got) + } + if got := getSetting(t, database, "registration_open"); got != "1" { + t.Errorf("registration_open = %q, want 1", got) + } + if got := getSetting(t, database, "max_upload_bytes"); got != "262144000" { + t.Errorf("max_upload_bytes = %q, want 262144000 (250 MB)", got) + } + if got := getSetting(t, database, "voice_quality"); got != "high" { + t.Errorf("voice_quality = %q, want high", got) + } + + // config.yaml written with the wizard values + persisted voice creds. + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("loading wizard-written config: %v", err) + } + if cfg.Server.Port != 9000 { + t.Errorf("config port = %d, want 9000", cfg.Server.Port) + } + if cfg.Server.Name != "My Cool Server" { + t.Errorf("config server name = %q", cfg.Server.Name) + } + if cfg.TLS.Mode != "off" { + t.Errorf("config tls mode = %q, want off", cfg.TLS.Mode) + } + if cfg.Upload.MaxSizeMB != 250 { + t.Errorf("config upload max = %d, want 250", cfg.Upload.MaxSizeMB) + } + if cfg.Voice.Quality != "high" { + t.Errorf("config voice quality = %q, want high", cfg.Voice.Quality) + } + if cfg.Voice.LiveKitAPIKey != "key-generated123" { + t.Errorf("LiveKit key = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPIKey) + } + if cfg.Voice.LiveKitAPISecret != "generated-secret-0123456789abcdef" { + t.Errorf("LiveKit secret = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPISecret) + } + if !cfg.Voice.AutoDownloadLiveKit { + t.Error("config voice.auto_download_livekit = false, want true from wizard toggle") + } +} + +func TestSetupWizard_NoRestartWhenValuesMatchRunning(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + // Same port/tls/upload/voice as the running config; only live-read + // values (name, motd) change. + rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{ + "username": "owner", + "password": "SecurePass123!", + "wizard": map[string]any{ + "server_name": "Renamed Server", + "motd": "hi", + "port": 8443, + "tls_mode": "self_signed", + "upload_max_size_mb": 100, + "voice_quality": "medium", + }, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String()) + } + + var resp struct { + RestartRequired bool `json:"restart_required"` + RestartURL string `json:"restart_url"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.RestartRequired { + t.Error("restart_required = true, want false (no startup-only value changed)") + } + if resp.RestartURL != "" { + t.Errorf("restart_url = %q, want empty", resp.RestartURL) + } + select { + case <-restarted: + t.Error("restart hook invoked though nothing needed a restart") + case <-time.After(100 * time.Millisecond): + } + + // Config is still written (server.name changed on disk). + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("loading wizard-written config: %v", err) + } + if cfg.Server.Name != "Renamed Server" { + t.Errorf("config server name = %q, want Renamed Server", cfg.Server.Name) + } +} + +func TestSetupWizard_InvalidValuesRejectBeforeAccountCreation(t *testing.T) { + cases := map[string]map[string]any{ + "port too low": {"port": 0}, + "port too high": {"port": 70000}, + "bad tls mode": {"tls_mode": "quantum"}, + "acme without domain": {"tls_mode": "acme"}, + "bad domain chars": {"tls_mode": "acme", "tls_domain": "not a domain!"}, + "single-label domain": {"tls_mode": "acme", "tls_domain": "localhost"}, + "upload zero": {"upload_max_size_mb": 0}, + "upload too large": {"upload_max_size_mb": 20000}, + "bad voice quality": {"voice_quality": "ultra"}, + "empty server name": {"server_name": " "}, + "tag-only server name": {"server_name": ""}, + } + + for name, wizard := range cases { + t.Run(name, func(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{ + "username": "owner", + "password": "SecurePass123!", + "wizard": wizard, + }) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String()) + } + count, err := database.UserCount(context.Background()) + if err != nil { + t.Fatalf("UserCount: %v", err) + } + if count != 0 { + t.Errorf("user count = %d, want 0 — invalid wizard payload must reject before account creation", count) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Error("config file written despite rejected payload") + } + }) + } +} + +func TestSetupWizard_ConfigWriteFailureWarnsButCreatesAccount(t *testing.T) { + database := openAdminTestDB(t) + // Point at a directory that does not exist so the atomic write fails. + cfgPath := filepath.Join(t.TempDir(), "missing-dir", "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{ + "username": "owner", + "password": "SecurePass123!", + "wizard": map[string]any{"port": 9000}, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("POST /setup = %d, want 201 despite config failure; body=%s", rr.Code, rr.Body.String()) + } + + var resp struct { + Token string `json:"token"` + RestartRequired bool `json:"restart_required"` + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" { + t.Error("token missing — the account must still be created") + } + if len(resp.Warnings) == 0 { + t.Error("warnings empty, want a config-write warning") + } + if resp.RestartRequired { + t.Error("restart_required = true, but the config was never written — restarting would change nothing") + } + select { + case <-restarted: + t.Error("restart hook invoked after a failed config write") + case <-time.After(100 * time.Millisecond): + } + + count, err := database.UserCount(context.Background()) + if err != nil { + t.Fatalf("UserCount: %v", err) + } + if count != 1 { + t.Errorf("user count = %d, want 1", count) + } +} + +func TestSetupWizard_LegacyPayloadUnchangedBehaviour(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "owner", + "password": "SecurePass123!", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("legacy POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String()) + } + + var resp struct { + RestartRequired bool `json:"restart_required"` + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.RestartRequired || len(resp.Warnings) != 0 { + t.Error("legacy payload must not trigger restarts or warnings") + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Error("legacy payload must not write config.yaml") + } + select { + case <-restarted: + t.Error("legacy payload must not restart the server") + case <-time.After(100 * time.Millisecond): + } +} + +func TestSetupStatus_DefaultsOnlyPreSetupAndSecretFree(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + rr := doRequest(t, handler, "GET", "/setup/status", "", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET /setup/status = %d, want 200", rr.Code) + } + var resp struct { + NeedsSetup bool `json:"needs_setup"` + Defaults *struct { + ServerName string `json:"server_name"` + Motd string `json:"motd"` + Port int `json:"port"` + TLSMode string `json:"tls_mode"` + UploadMaxSizeMB int `json:"upload_max_size_mb"` + VoiceQuality string `json:"voice_quality"` + } `json:"defaults"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.NeedsSetup || resp.Defaults == nil { + t.Fatalf("pre-setup status should carry defaults; body=%s", rr.Body.String()) + } + // server_name/motd come from the seeded settings table, the rest from the + // running config. + if resp.Defaults.ServerName != "Test Server" { + t.Errorf("defaults.server_name = %q, want Test Server (DB value)", resp.Defaults.ServerName) + } + if resp.Defaults.Motd != "Hello" { + t.Errorf("defaults.motd = %q, want Hello (DB value)", resp.Defaults.Motd) + } + if resp.Defaults.Port != 8443 || resp.Defaults.TLSMode != "self_signed" || + resp.Defaults.UploadMaxSizeMB != 100 || resp.Defaults.VoiceQuality != "medium" { + t.Errorf("config-derived defaults wrong: %+v", resp.Defaults) + } + // Never leak credentials through the unauthenticated status endpoint. + lower := strings.ToLower(rr.Body.String()) + for _, needle := range []string{"livekit", "secret", "api_key", "token", "cidr"} { + if strings.Contains(lower, needle) { + t.Errorf("status response leaks %q: %s", needle, rr.Body.String()) + } + } + + // After setup completes, defaults disappear along with needs_setup. + rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "owner", "password": "SecurePass123!", + }) + if rr2.Code != http.StatusCreated { + t.Fatalf("setup = %d, want 201", rr2.Code) + } + rr3 := doRequest(t, handler, "GET", "/setup/status", "", nil) + if !strings.Contains(rr3.Body.String(), `"needs_setup":false`) { + t.Errorf("post-setup status = %s, want needs_setup false", rr3.Body.String()) + } + if strings.Contains(rr3.Body.String(), "defaults") { + t.Errorf("post-setup status still exposes defaults: %s", rr3.Body.String()) + } +} + +func TestSetupWizard_ForeignOriginBlocked(t *testing.T) { + database := openAdminTestDB(t) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan string, 1) + handler := wizardHandler(t, database, cfgPath, restarted) + + body := map[string]any{ + "username": "owner", + "password": "SecurePass123!", + "wizard": map[string]any{"port": 9000}, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + req := httptest.NewRequest("POST", "/setup", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://evil.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("wizard POST from foreign origin = %d, want 403", rr.Code) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Error("config file written from a cross-origin request") + } +} diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 01d3e0f4..c1bc8f9d 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -167,6 +167,22 @@ .auth-box{background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-md);padding:32px;width:380px} .auth-box h2{font-size:20px;font-weight:700;color:white;margin-bottom:20px} .auth-error{color:var(--red);font-size:13px;margin-top:10px;min-height:20px} + .auth-box.wizard{width:560px;max-width:94vw;max-height:92vh;overflow-y:auto} + .wiz-steps{display:flex;gap:6px;margin-bottom:20px} + .wiz-dot{height:4px;flex:1;border-radius:2px;background:var(--border);transition:background .2s} + .wiz-dot.active{background:var(--accent)} + .wiz-dot.done{background:var(--accent);opacity:.45} + .wiz-sub{color:var(--text-muted);font-size:14px;margin-bottom:20px;line-height:1.5} + .wiz-hint{color:var(--text-faint);font-size:12px;margin-top:6px;line-height:1.4} + .wiz-nav{display:flex;justify-content:space-between;gap:8px;margin-top:24px} + .wiz-review-row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px} + .wiz-review-row .k{color:var(--text-muted);white-space:nowrap} + .wiz-review-row .v{color:var(--text-normal);font-weight:600;text-align:right;word-break:break-word} + .wiz-callout{background:rgba(240,178,50,.1);border:1px solid rgba(240,178,50,.35);color:var(--yellow);font-size:13px;padding:10px 12px;border-radius:var(--radius-sm);margin-top:16px;line-height:1.45} + .wiz-skip{display:block;width:100%;text-align:center;margin-top:14px;color:var(--text-faint);font-size:12px;cursor:pointer;text-decoration:underline;background:none} + .wiz-skip:hover{color:var(--text-muted)} + .wiz-toggle-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px} + .wiz-toggle-row .lbl{font-size:14px;color:var(--text-normal)} /* Log viewer */ .log-toolbar{display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap} @@ -199,17 +215,9 @@ - +
-
-

Welcome to OwnCord

-

No accounts exist yet. Create the owner account to get started.

-
-
-
- -
-
+
@@ -219,6 +227,12 @@

Your owner account has been created. Here's your invite code:

Save this code! Share it with people you want to invite.

+
+ @@ -278,21 +292,36 @@ const I={ megaphone:'', logs:'', lock:'', + plugins:'', + upload:'', }; /* ═══ State ═══ */ const PAGE_SIZE=50; const state={section:'dashboard',token:localStorage.getItem('admin_token')||'', usersPage:1,auditPage:1,auditSearch:'',auditActionFilter:'all',auditCache:[],settingsChanged:false,backupRunning:false,updateApplying:false, - cachedStats:null,cachedUpdate:null, + cachedStats:null,cachedUpdate:null,channelCache:{},pluginRuntime:'unknown',pluginBusy:false, logEntries:[],logLevels:{DEBUG:true,INFO:true,WARN:true,ERROR:true}, logSearch:'',logAutoScroll:true,logPaused:false,logEventSource:null,logReconnectTimer:null,logConnectSeq:0,logMaxLines:2000}; /* ═══ API ═══ */ +/* A 401 means the admin session is gone. Handle it here rather than letting + every call site toast "invalid or expired session" forever while the panel + stays on screen with no way back to the login form. */ +function handleSessionExpired(){ + state.logConnectSeq++; + if(state.logEventSource){state.logEventSource.close();state.logEventSource=null} + if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null} + state.token='';localStorage.removeItem('admin_token'); + const err=document.getElementById('loginErr');if(err)err.textContent='Your session expired — sign in again.'; + showOverlay('loginOverlay'); +} + async function api(method,path,body){ const opts={method,headers:{'Authorization':'Bearer '+state.token,'Content-Type':'application/json'}}; if(body!==undefined)opts.body=JSON.stringify(body); const res=await fetch('/admin/api'+path,opts); + if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')} if(res.status===204)return null; const data=await res.json(); if(!res.ok)throw new Error(data.message||res.statusText); @@ -334,33 +363,238 @@ function showOverlay(id){hideAll();document.getElementById(id).classList.add('vi function showApp(){hideAll();document.getElementById('adminShell').classList.remove('hidden')} async function checkAuth(){ - try{const r=await fetch('/admin/api/setup/status');const d=await r.json();if(d.needs_setup){showOverlay('setupOverlay');return}}catch(e){console.error('setup check:',e)} + try{const r=await fetch('/admin/api/setup/status');const d=await r.json();if(d.needs_setup){wizInit(d.defaults);showOverlay('setupOverlay');return}}catch(e){console.error('setup check:',e)} if(!state.token){showOverlay('loginOverlay');return} try{await api('GET','/stats');showApp();renderNav();renderContent()}catch(e){showOverlay('loginOverlay')} } -document.getElementById('setupBtn').onclick=async()=>{ - const u=document.getElementById('setupUser').value.trim(),p=document.getElementById('setupPass').value,c=document.getElementById('setupConfirm').value,err=document.getElementById('setupErr'); - err.textContent=''; - if(!u||!p){err.textContent='Username and password are required.';return} - if(p!==c){err.textContent='Passwords do not match.';return} - try{const r=await fetch('/admin/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});const d=await r.json();if(!r.ok)throw new Error(d.message||'Setup failed'); - state.token=d.token;localStorage.setItem('admin_token',state.token); - document.getElementById('inviteCode').textContent=d.invite_code;showOverlay('setupSuccessOverlay'); - }catch(e){err.textContent=e.message} -}; +/* ═══ First-Run Setup Wizard ═══ */ +/* Multi-step overlay shown while needs_setup is true. Collects the owner + account plus the basics (name, port, security, uploads, voice, access) and + submits everything as one POST /admin/api/setup — the server writes both + the settings table and config.yaml, and restarts itself if startup-only + values changed. "Skip" falls back to the legacy account-only payload. */ +const wiz={step:0,skip:false,defaults:null,data:{},busy:false}; +const WIZ_STEP_COUNT=6; + +function wizInit(defaults){ + wiz.defaults=defaults||null;wiz.step=0;wiz.skip=false;wiz.busy=false; + const d=defaults||{}; + wiz.data={username:'',password:'',confirm:'', + server_name:d.server_name||'OwnCord Server', + motd:(d.motd===undefined||d.motd===null)?'Welcome!':d.motd, + registration_open:!!d.registration_open, + port:d.port||8443, + tls_mode:d.tls_mode||'self_signed', + tls_domain:d.tls_domain||'', + upload_max_size_mb:d.upload_max_size_mb||100, + voice_quality:d.voice_quality||'medium', + voice_auto_download:d.voice_auto_download!==undefined?!!d.voice_auto_download:true}; + renderWizard(); +} + +function wizDots(){ + let h='
'; + for(let i=0;i
'; + return h+''; +} + +function wizField(id,label,input){return '
'+input+'
'} + +function renderWizard(){ + const box=document.getElementById('wizardBox'); + const d=wiz.data; + let h=wizDots(); + const err='
'; + const nav=nextLabel=>'
'; + switch(wiz.step){ + case 0: + h+='

Welcome to OwnCord

' + +'

Your own private chat server is almost ready. This one-minute setup creates your admin account and configures the basics — no config files to edit, everything is saved for you.

' + +'' + +''; + break; + case 1: + h+='

Create your admin account

' + +'

This is the owner account for managing the server. Pick a strong password — this account can do everything.

' + +wizField('wizUser','Username','') + +wizField('wizPass','Password','') + +wizField('wizConfirm','Confirm Password','') + +err+nav(wiz.skip?'Create Owner Account':'Next'); + break; + case 2: + h+='

Server basics

' + +'

How your server introduces itself, and how people connect to it.

' + +wizField('wizName','Server Name','') + +wizField('wizPort','Port','
The network port people connect to. Keep the default unless it clashes with something else on this machine.
') + +wizField('wizTLS','Security','
') + +'' + +err+nav('Next'); + break; + case 3: + h+='

Uploads & voice

' + +'

Limits for file sharing and voice chat quality.

' + +wizField('wizUpload','Max upload size (MB)','
The largest file anyone can share. 100 MB suits most servers.
') + +'
Voice chat
Downloads the voice engine (LiveKit, ~40 MB, one time) from the official LiveKit project and manages it for you. Turn off only if you run your own LiveKit server.
' + +wizField('wizVoice','Voice quality','') + +err+nav('Next'); + break; + case 4: + h+='

Who can join?

' + +'

You'll get an invite code either way — these control what happens after that.

' + +'
Open registration
Allow new people to create accounts using invite codes. Turn off to lock the server to existing members.
' + +wizField('wizMotd','Welcome message','
Shown to members when they connect.
') + +err+nav('Next'); + break; + case 5:{ + const secLabel={self_signed:'Self-signed HTTPS',acme:'Let's Encrypt ('+esc(d.tls_domain)+')',manual:'Manual certificates',off:'No encryption'}[d.tls_mode]||esc(d.tls_mode); + const rows=[['Username',esc(d.username)],['Server name',esc(d.server_name)],['Port',esc(d.port)],['Security',secLabel],['Max upload',esc(d.upload_max_size_mb)+' MB'],['Voice chat',d.voice_auto_download?'Automatic (LiveKit downloaded for you)':'Self-managed / off'],['Voice quality',esc(d.voice_quality)],['Open registration',d.registration_open?'Yes':'No'],['Welcome message',esc(d.motd)||'—']]; + h+='

Review & finish

Everything look right? You can change any of this later in the admin panel.

'; + rows.forEach(r=>{h+='
'+r[0]+''+r[1]+'
'}); + if(wizNeedsRestart())h+='
The server will restart once to apply your connection settings, then point you to the right address.
'; + h+=err+nav('Finish Setup'); + break;} + } + box.innerHTML=h; + if(wiz.step===2)wizTLSChanged(); + box.querySelectorAll('input').forEach(el=>el.addEventListener('keydown',e=>{if(e.key==='Enter')wizNext()})); + const first=box.querySelector('input');if(first)first.focus(); +} + +function wizTLSChanged(){ + const sel=document.getElementById('wizTLS');if(!sel)return; + const hints={ + self_signed:'Works out of the box on your network. Browsers show a one-time security warning you can safely accept.', + acme:'A free, trusted certificate from Let’s Encrypt. Only choose this if you own a domain that points at this machine.', + manual:'Bring your own certificate files (data/cert.pem and data/key.pem).', + off:'Traffic is unencrypted. Only for testing, or behind a reverse proxy that handles HTTPS.'}; + document.getElementById('wizTLSHint').textContent=hints[sel.value]||''; + document.getElementById('wizDomainGroup').style.display=sel.value==='acme'?'block':'none'; +} + +function wizNeedsRestart(){ + const f=wiz.defaults;if(!f)return false;const d=wiz.data; + return Number(d.port)!==f.port||d.tls_mode!==f.tls_mode||Number(d.upload_max_size_mb)!==f.upload_max_size_mb||d.voice_quality!==f.voice_quality||d.voice_auto_download!==!!f.voice_auto_download||(d.tls_mode==='acme'&&d.tls_domain!==(f.tls_domain||'')); +} + +function wizCollect(){ + const g=id=>{const el=document.getElementById(id);return el?el.value:undefined}; + const d=wiz.data; + switch(wiz.step){ + case 1:d.username=(g('wizUser')||'').trim();d.password=g('wizPass')||'';d.confirm=g('wizConfirm')||'';break; + case 2:d.server_name=(g('wizName')||'').trim();d.port=g('wizPort');d.tls_mode=g('wizTLS')||d.tls_mode;d.tls_domain=(g('wizDomain')||'').trim();break; + case 3:{d.upload_max_size_mb=g('wizUpload');d.voice_quality=g('wizVoice')||d.voice_quality;const vd=document.getElementById('wizVoiceDl');if(vd)d.voice_auto_download=vd.classList.contains('on');break} + case 4:{const t=document.getElementById('wizReg');if(t)d.registration_open=t.classList.contains('on');d.motd=(g('wizMotd')||'').trim();break} + } +} + +function wizBack(){ + if(wiz.busy)return; + wizCollect(); + if(wiz.step===1)wiz.skip=false; + wiz.step=Math.max(0,wiz.step-1); + renderWizard(); +} + +function wizSkip(){wiz.skip=true;wiz.step=1;renderWizard()} + +function wizNext(){ + if(wiz.busy)return; + wizCollect(); + const d=wiz.data; + const fail=msg=>{const e=document.getElementById('wizErr');if(e)e.textContent=msg}; + switch(wiz.step){ + case 1: + if(!d.username||!d.password)return fail('Username and password are required.'); + if(d.password.length<8)return fail('Password must be at least 8 characters.'); + if(d.password!==d.confirm)return fail('Passwords do not match.'); + if(wiz.skip)return wizFinish(); + break; + case 2:{ + if(!d.server_name)return fail('Server name is required.'); + const p=Number(d.port); + if(!Number.isInteger(p)||p<1||p>65535)return fail('Port must be a number between 1 and 65535.'); + if(d.tls_mode==='acme'&&!d.tls_domain)return fail('A domain is required for Let’s Encrypt.'); + break;} + case 3:{ + const u=Number(d.upload_max_size_mb); + if(!Number.isInteger(u)||u<1||u>10240)return fail('Max upload size must be between 1 and 10240 MB.'); + break;} + case 5:return wizFinish(); + } + wiz.step++;renderWizard(); +} + +async function wizFinish(){ + if(wiz.busy)return;wiz.busy=true; + const btn=document.getElementById('wizNextBtn');if(btn){btn.disabled=true;btn.innerHTML='
Setting up…'} + const d=wiz.data; + const body={username:d.username,password:d.password}; + if(!wiz.skip){ + body.wizard={server_name:d.server_name,motd:d.motd,registration_open:!!d.registration_open, + port:Number(d.port),tls_mode:d.tls_mode,upload_max_size_mb:Number(d.upload_max_size_mb), + voice_quality:d.voice_quality,voice_auto_download:!!d.voice_auto_download}; + if(d.tls_mode==='acme')body.wizard.tls_domain=d.tls_domain; + } + try{ + const r=await fetch('/admin/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const resp=await r.json();if(!r.ok)throw new Error(resp.message||'Setup failed'); + state.token=resp.token;localStorage.setItem('admin_token',state.token); + document.getElementById('inviteCode').textContent=resp.invite_code; + const warn=document.getElementById('setupWarnings');warn.innerHTML=''; + (resp.warnings||[]).forEach(wm=>{const div=document.createElement('div');div.className='wiz-callout';div.style.marginBottom='12px';div.textContent=wm;warn.appendChild(div)}); + showOverlay('setupSuccessOverlay'); + if(resp.restart_required&&resp.restart_url)beginRestartWait(resp.restart_url); + }catch(e){ + const err=document.getElementById('wizErr');if(err)err.textContent=e.message; + const b=document.getElementById('wizNextBtn');if(b){b.disabled=false;b.textContent=wiz.step===1?'Create Owner Account':'Finish Setup'} + }finally{wiz.busy=false} +} + +/* Poll until the restarted server answers, then follow it. no-cors: an opaque + response resolving means "up" even across a port change; rejection means + still down. A self-signed cert the browser hasn't accepted yet keeps the + poll failing — the visible link is the primary path, this redirect is + best-effort sugar. */ +function beginRestartWait(url){ + document.getElementById('setupContinueBtn').style.display='none'; + document.getElementById('setupRestart').style.display='block'; + const link=document.getElementById('restartLink');link.href=url;link.textContent=url; + let elapsed=0; + setTimeout(function poll(){ + fetch(url+'/api/setup/status',{mode:'no-cors',cache:'no-store'}) + .then(()=>{window.location=url}) + .catch(()=>{elapsed+=2000;if(elapsed<60000)setTimeout(poll,2000)}); + },4000); +} + document.getElementById('setupContinueBtn').onclick=()=>{showApp();renderNav();renderContent()}; -function copyInvite(){navigator.clipboard.writeText(document.getElementById('inviteCode').textContent).then(()=>showToast('Copied!','info'))} +function copyInvite(){navigator.clipboard.writeText(document.getElementById('inviteCode').textContent).then(()=>showToast('Copied!','info')).catch(()=>showToast('Copy failed','error'))} document.getElementById('loginBtn').onclick=async()=>{ + const btn=document.getElementById('loginBtn'); const u=document.getElementById('loginUser').value.trim(),p=document.getElementById('loginPass').value,err=document.getElementById('loginErr'); err.textContent=''; + if(!u||!p){err.textContent='Username and password are required.';return} + // Each submit counts against the login lockout counter — don't spend two. + if(btn.disabled)return; + btn.disabled=true; try{const r=await fetch('/api/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed'); state.token=d.token;localStorage.setItem('admin_token',state.token);showApp();renderNav();renderContent(); }catch(e){err.textContent=e.message} + finally{btn.disabled=false} }; -['setupUser','setupPass','setupConfirm'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('setupBtn').click()})); +/* Wizard inputs bind Enter dynamically in renderWizard(). */ ['loginUser','loginPass'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('loginBtn').click()})); /* ═══ Nav ═══ */ @@ -373,6 +607,7 @@ const NAV=[ {section:'Configuration'}, {id:'audit',label:'Audit Log',icon:I.audit}, {id:'tokens',label:'API Tokens',icon:I.lock}, + {id:'plugins',label:'Plugins',icon:I.plugins}, {id:'logs',label:'Server Logs',icon:I.logs}, {id:'settings',label:'Settings',icon:I.settings,unsaved:()=>state.settingsChanged}, {id:'backups',label:'Backups',icon:I.backup}, @@ -409,7 +644,7 @@ function doLogout(){state.logConnectSeq++;if(state.logEventSource){state.logEven /* ═══ Content Router ═══ */ function renderContent(){ const c=document.getElementById('content');if(!c)return;c.scrollTop=0; - const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,audit:renderAudit,tokens:renderTokens,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; + const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,audit:renderAudit,tokens:renderTokens,plugins:renderPlugins,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; c.innerHTML='
Loading...
'; const fn=r[state.section]; if(typeof fn!=='function'){console.error('[Admin] No render function for section: '+state.section);c.innerHTML='
Error

Unknown section: '+esc(state.section)+'

';return} @@ -431,7 +666,7 @@ function renderContent(){ /* ═══ Dashboard ═══ */ async function renderDashboard(){ try{state.cachedStats=await api('GET','/stats')}catch(e){return'
Dashboard

Failed to load stats: '+esc(e.message)+'

'} - try{const r=await fetch('/admin/api/updates',{headers:{'Authorization':'Bearer '+state.token}});if(r.ok)state.cachedUpdate=await r.json()}catch(e){} + try{state.cachedUpdate=await api('GET','/updates')}catch(e){/* the banner is optional; the Updates page reports the failure */} const s=state.cachedStats;const u=state.cachedUpdate; let html='
Dashboard
Server overview and statistics
'; if(u&&u.update_available)html+='
'+I.updates+'
Update Available: '+esc(u.latest)+'
Current: '+esc(u.current)+' —
'; @@ -470,7 +705,14 @@ async function renderUsers(){ html+='
'+initial+'
'+esc(uname)+'
'; html+=''+roleName(rid)+''; html+=''+statusLabel+''; - html+=''+(banned?'Yes':'No')+''; + // The ban reason is collected on ban and stored server-side; showing it + // here is the only place an admin can read back why someone was banned. + const banReason=u.ban_reason||u.BanReason||''; + const bannedCell=banned + ?'Yes' + +(banReason?'
'+esc(banReason)+'
':'') + :'No'; + html+=''+bannedCell+''; html+='
'; html+=''; html+=''; @@ -534,7 +776,8 @@ async function renderChannels(){ html+=''+esc(cat)+''; html+=''+(archived?'Yes':'No')+''; const lockBtn=type==='dm'?'':''; - html+='
'+lockBtn+'
'; + state.channelCache[id]=ch; + html+='
'+lockBtn+'
'; }); html+='
'; return html; @@ -550,13 +793,38 @@ async function createChannel(){ try{await api('POST','/channels',body);closeModal();showToast('Channel created');renderContent()}catch(e){showToast(e.message,'error')} } -function openChannelEditModal(id,name){ - openModal(''); +/* PATCH /channels/{id} accepts name, topic, slow_mode, position and archived — + the modal used to offer only the name, so the Archived column in the table + was read-only state with no control behind it. */ +function openChannelEditModal(id){ + const ch=state.channelCache[id]||{}; + const name=ch.name||ch.Name||''; + const topic=ch.topic||ch.Topic||''; + const slow=ch.slow_mode||ch.SlowMode||0; + const pos=ch.position||ch.Position||0; + const archived=ch.archived||ch.Archived||false; + openModal('' + +'' + +''); } async function saveChannelEdit(id){ const name=document.getElementById('chEditName').value.trim(); - try{await api('PATCH','/channels/'+id,{name});closeModal();showToast('Channel updated');renderContent()}catch(e){showToast(e.message,'error')} + if(!name){showToast('Name is required','error');return} + const body={ + name, + topic:document.getElementById('chEditTopic').value.trim(), + slow_mode:parseInt(document.getElementById('chEditSlow').value,10)||0, + position:parseInt(document.getElementById('chEditPos').value,10)||0, + archived:document.getElementById('chEditArchived').classList.contains('on'), + }; + try{await api('PATCH','/channels/'+id,body);closeModal();showToast('Channel updated');renderContent()}catch(e){showToast(e.message,'error')} } function openDeleteChannel(id,name){ @@ -825,7 +1093,16 @@ async function saveSettings(){ const body={}; ['server_name','server_icon','motd','max_upload_bytes','voice_quality','backup_schedule','backup_retention'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.value}); ['require_2fa','registration_open'].forEach(k=>{const el=document.getElementById('s-'+k);if(el)body[k]=el.classList.contains('on')?'true':'false'}); - try{await api('PATCH','/settings',body);state.settingsChanged=false;renderNav();showToast('Settings saved')}catch(e){showToast(e.message,'error')} + const btn=document.getElementById('saveSettingsBtn'); + if(btn){if(btn.disabled)return;btn.disabled=true} + try{ + await api('PATCH','/settings',body); + state.settingsChanged=false;renderNav();showToast('Settings saved'); + // Leave the button disabled: there are no unsaved changes any more. + }catch(e){ + showToast(e.message,'error'); + if(btn)btn.disabled=false; + } } /* ═══ Backups ═══ */ @@ -840,7 +1117,7 @@ async function renderBackups(){ else backups.forEach(b=>{ html+=''+esc(b.name)+''; html+=''+fmtBytes(b.size)+''+(b.date?new Date(b.date).toLocaleString():'')+''; - html+='
'; + html+='
'; }); html+=''; return html; @@ -859,8 +1136,15 @@ async function confirmRestore(name){ try{await api('POST','/backups/'+encodeURIComponent(name)+'/restore');closeModal();showToast('Database restored. Restart recommended.','info');renderContent()}catch(e){showToast(e.message,'error')} } +/* Deleting a backup is irreversible — confirm it like every other destructive + action here. It also used to report success without looking at the response, + so a failed delete said "Backup deleted" and left the file in place. */ +function openDeleteBackupModal(name){ + openModal(''); +} + async function confirmDeleteBackup(name){ - try{await fetch('/admin/api/backups/'+encodeURIComponent(name),{method:'DELETE',headers:{'Authorization':'Bearer '+state.token}});showToast('Backup deleted');renderContent()}catch(e){showToast(e.message,'error')} + try{await api('DELETE','/backups/'+encodeURIComponent(name));closeModal();showToast('Backup deleted');renderContent()}catch(e){showToast(e.message,'error')} } /* ═══ API Tokens ═══ */ @@ -916,7 +1200,7 @@ function showTokenOnceModal(d){ ''+ ''); } -function copyToken(t){navigator.clipboard.writeText(t).then(()=>showToast('Copied!','info'))} +function copyToken(t){navigator.clipboard.writeText(t).then(()=>showToast('Copied!','info')).catch(()=>showToast('Copy failed — select the token and copy it manually','error'))} function confirmRevokeToken(id,label){ openModal(''); @@ -925,14 +1209,153 @@ async function revokeToken(id){ try{await api('DELETE','/tokens/'+id);closeModal();showToast('Token revoked');renderContent()}catch(e){showToast(e.message,'error')} } +/* ═══ Plugins ═══ */ +/* The plugin lifecycle API lives under /api/v1/admin/plugins (same admin auth + and IP gate, different prefix), so it needs its own fetch helper rather than + api(). Errors come back as plain text from http.Error, not JSON. */ +async function pluginApi(method,path,opts){ + const init={method,headers:{'Authorization':'Bearer '+state.token}}; + if(opts&&opts.body!==undefined)init.body=opts.body; + const res=await fetch('/api/v1/admin/plugins'+path,init); + if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')} + if(res.status===204)return{data:null,res}; + const text=await res.text(); + let data=null; + if(text){try{data=JSON.parse(text)}catch(e){data=null}} + if(!res.ok){ + const msg=(data&&(data.message||data.error))||text.trim()||res.statusText; + throw new Error(msg); + } + return{data,res}; +} + +function pluginManifestSummary(row){ + const raw=row.manifest_json||row.ManifestJSON||''; + if(!raw)return''; + try{ + const m=JSON.parse(raw); + const bits=[]; + if(m.description)bits.push(m.description); + if(Array.isArray(m.permissions)&&m.permissions.length)bits.push('permissions: '+m.permissions.join(', ')); + return bits.join(' — '); + }catch(e){return''} +} + +async function renderPlugins(){ + let rows; + try{ + const out=await pluginApi('GET','/'); + rows=out.data||[]; + state.pluginRuntime=out.res.headers.get('X-Plugin-Runtime')||'unknown'; + }catch(e){ + return'
Plugins

'+esc(e.message)+'

'; + } + + const disabled=state.pluginRuntime==='disabled'; + let html='
Plugins
Install and manage server plugins
'; + + if(disabled){ + html+='
Plugin runtime is disabled on this server.
Installed plugins are listed below but cannot be installed, enabled, or removed until the runtime is turned on in the server configuration.
'; + }else{ + html+='

Install Plugin

'; + html+='
Upload a plugin package (.zip, max 16 MB) containing a plugin.json manifest at its root.
'; + html+='
'; + html+=''; + html+=''; + html+='
'; + } + + html+='

Installed

'; + html+=''; + if(!rows.length){ + const empty=disabled?'No plugins installed — and the runtime is off':'No plugins installed yet'; + html+=''; + }else rows.forEach(row=>{ + const id=row.id!==undefined?row.id:row.ID; + const name=row.name||row.Name||''; + const version=row.version||row.Version||''; + const enabled=row.enabled!==undefined?row.enabled:row.Enabled; + const installed=row.installed_at||row.InstalledAt||''; + const summary=pluginManifestSummary(row); + html+=''; + html+=''; + html+=''; + html+=''; + html+=''; + }); + html+='
PluginVersionStatusInstalledActions
'+empty+'
'+esc(name)+''+(summary?'
'+esc(summary)+'
':'')+'
'+esc(version||'—')+''+(enabled?'Enabled':'Disabled')+''+(installed?new Date(installed).toLocaleString():'')+'
'; + if(disabled){ + html+='runtime off'; + }else{ + html+=''; + html+=''; + } + html+='
'; + return html; +} + +async function installPlugin(){ + const input=document.getElementById('pluginFile'); + const btn=document.getElementById('pluginInstallBtn'); + const file=input&&input.files&&input.files[0]; + if(!file){showToast('Choose a .zip package first','error');return} + if(state.pluginBusy)return; + state.pluginBusy=true; + if(btn){btn.disabled=true;btn.textContent='Installing...'} + const fd=new FormData(); + fd.append('plugin',file); + try{ + // No explicit Content-Type: the browser must set the multipart boundary. + const out=await pluginApi('POST','/install',{body:fd}); + const name=(out.data&&out.data.name)||file.name; + showToast('Installed '+name); + state.pluginBusy=false; + renderContent(); + }catch(e){ + state.pluginBusy=false; + showToast(e.message,'error'); + if(btn){btn.disabled=false;btn.textContent='Install'} + } +} + +async function setPluginEnabled(id,enable){ + if(state.pluginBusy)return; + state.pluginBusy=true; + try{ + await pluginApi('POST','/'+id+'/'+(enable?'enable':'disable')); + showToast(enable?'Plugin enabled':'Plugin disabled'); + }catch(e){showToast(e.message,'error')} + state.pluginBusy=false; + renderContent(); +} + +function openUninstallPlugin(id,name){ + openModal(''); +} + +async function uninstallPlugin(id){ + if(state.pluginBusy)return; + state.pluginBusy=true; + try{ + await pluginApi('DELETE','/'+id); + closeModal(); + showToast('Plugin uninstalled'); + }catch(e){showToast(e.message,'error')} + state.pluginBusy=false; + renderContent(); +} + /* ═══ Updates ═══ */ async function renderUpdates(){ - let info; - try{const r=await fetch('/admin/api/updates',{headers:{'Authorization':'Bearer '+state.token}});if(r.ok)info=await r.json()}catch(e){} + // A failed check is not the same as "up to date" — saying so would be a lie + // that hides a broken update path. + let info,checkError=''; + try{info=await api('GET','/updates')}catch(e){checkError=e.message||'Update check failed'} let html='
Updates
Server version management
'; html+='
'; html+='
'+I.check+'
'+(info?esc(info.current):'unknown')+'
Current version
'; - if(info&&info.update_available)html+='
'+I.updates+'
'+esc(info.latest)+' New
Available for download
'; + if(checkError)html+='
'+I.ban+'
Check failed
'+esc(checkError)+'
'; + else if(info&&info.update_available)html+='
'+I.updates+'
'+esc(info.latest)+' New
Available for download
'; else html+='
'+I.check+'
Up to date
You\'re running the latest version
'; html+='
'; if(info&&info.update_available){ @@ -952,10 +1375,14 @@ async function confirmApplyUpdate(){ closeModal();state.updateApplying=true;renderContent(); try{ const r=await fetch('/admin/api/updates/apply',{method:'POST',headers:{'Authorization':'Bearer '+state.token}}); - if(r.ok){showToast('Update applied! Server restarting...','info');setTimeout(()=>location.reload(),10000)} - else{const e=await r.json();showToast(e.message||'Update failed','error')} + if(r.ok){showToast('Update applied! Server restarting...','info');setTimeout(()=>location.reload(),10000);return} + let msg='Update failed'; + try{const e=await r.json();msg=e.message||msg}catch(parseErr){} + showToast(msg,'error'); }catch(e){showToast(e.message,'error')} - state.updateApplying=false; + // Failure path only: re-render so the button leaves its "Applying..." state + // instead of staying disabled until the next navigation. + state.updateApplying=false;renderContent(); } /* ═══ Keyboard + Init ═══ */ diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 666b9ccb..74dd3f65 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -508,7 +508,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle } // Per-user lockout to prevent password brute-force on this destructive endpoint. - lockKey := fmt.Sprintf("delete_lock:%d", user.ID) + lockKey := auth.Key("delete_lock", user.ID) if limiter.IsLockedOut(lockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -535,7 +535,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle } // Verify the supplied password matches the stored hash. - failKey := fmt.Sprintf("delete_fail:%d", user.ID) + failKey := auth.Key("delete_fail", user.ID) if !auth.CheckPassword(user.PasswordHash, req.Password) { if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) { limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration) diff --git a/Server/api/crs_fs.go b/Server/api/crs_fs.go new file mode 100644 index 00000000..4f111540 --- /dev/null +++ b/Server/api/crs_fs.go @@ -0,0 +1,51 @@ +package api + +import ( + "io/fs" + "strings" +) + +// slashFS wraps an fs.FS so that path arguments arriving with OS-specific +// separators are normalized to the forward slashes that io/fs and embed.FS +// require. +// +// This exists to keep the embedded OWASP CRS ruleset loadable on Windows. +// coraza's seclang parser resolves Include globs through path/filepath: for +// each glob match it does filepath.Join(currentDir, match) and +// filepath.Dir(...) (see coraza/internal/seclang parser FromFile). On Windows +// filepath.Join/Clean rewrite the forward slashes to backslashes, so the names +// coraza then feeds back into fs.ReadFile look like +// "@owasp_crs\\REQUEST-901-INITIALIZATION.conf". The embedded CRS filesystem is +// an embed.FS, which is always forward-slash and rejects such a name, so CRS +// engine initialization fails outright on Windows (every CRS rule file under a +// subdirectory is unreachable). Normalizing here fixes it on every OS without +// patching coraza or the ruleset module, and is a no-op on platforms whose +// separator is already "/". +type slashFS struct { + inner fs.FS +} + +func toSlashPath(name string) string { + return strings.ReplaceAll(name, "\\", "/") +} + +func (s slashFS) Open(name string) (fs.File, error) { + return s.inner.Open(toSlashPath(name)) +} + +// ReadFile satisfies fs.ReadFileFS; coraza uses fs.ReadFile to load each +// included rule file, which dispatches here when the wrapper is the root FS. +func (s slashFS) ReadFile(name string) ([]byte, error) { + return fs.ReadFile(s.inner, toSlashPath(name)) +} + +// ReadDir satisfies fs.ReadDirFS for completeness (glob traversal fallbacks). +func (s slashFS) ReadDir(name string) ([]fs.DirEntry, error) { + return fs.ReadDir(s.inner, toSlashPath(name)) +} + +// Glob satisfies fs.GlobFS; coraza uses fs.Glob to expand "Include" patterns +// like "@owasp_crs/*.conf". The delegate returns forward-slash matches. +func (s slashFS) Glob(pattern string) ([]string, error) { + return fs.Glob(s.inner, toSlashPath(pattern)) +} diff --git a/Server/api/crs_fs_test.go b/Server/api/crs_fs_test.go new file mode 100644 index 00000000..52e348f6 --- /dev/null +++ b/Server/api/crs_fs_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "io/fs" + "testing" + + coreruleset "github.com/corazawaf/coraza-coreruleset/v4" +) + +// backslashInclude is the exact name coraza's parser produces on Windows when +// expanding "Include @owasp_crs/*.conf": filepath.Join rewrites the separator, +// so the CRS FS is asked for a backslash path. This is what broke Windows CI. +const backslashInclude = `@owasp_crs\REQUEST-901-INITIALIZATION.conf` + +// TestSlashFS_ResolvesBackslashPath reproduces the Windows CRS-load failure on +// any OS: the embedded ruleset FS cannot resolve a backslash path, but the +// slashFS wrapper normalizes it and resolves it. Runs identically on Linux +// because the backslash name is constructed literally, not via filepath. +func TestSlashFS_ResolvesBackslashPath(t *testing.T) { + // The raw coreruleset FS rejects a backslash path (embed.FS is + // forward-slash only) — exactly the Windows failure mode. + if _, err := fs.ReadFile(coreruleset.FS, backslashInclude); err == nil { + t.Fatalf("expected raw coreruleset FS to fail on %q, got nil error", backslashInclude) + } + + // The wrapper normalizes the separator and resolves the file. + data, err := fs.ReadFile(slashFS{coreruleset.FS}, backslashInclude) + if err != nil { + t.Fatalf("slashFS.ReadFile(%q): %v", backslashInclude, err) + } + if len(data) == 0 { + t.Fatalf("slashFS.ReadFile(%q) returned empty file", backslashInclude) + } + + // A forward-slash path still works (no-op normalization). + if _, err := fs.ReadFile(slashFS{coreruleset.FS}, "@owasp_crs/REQUEST-901-INITIALIZATION.conf"); err != nil { + t.Fatalf("slashFS.ReadFile forward-slash: %v", err) + } +} + +// TestSlashFS_GlobNormalizes verifies the Glob passthrough returns the CRS rule +// files, which is how coraza expands the "@owasp_crs/*.conf" include. +func TestSlashFS_GlobNormalizes(t *testing.T) { + matches, err := fs.Glob(slashFS{coreruleset.FS}, "@owasp_crs/*.conf") + if err != nil { + t.Fatalf("slashFS.Glob: %v", err) + } + if len(matches) == 0 { + t.Fatal("slashFS.Glob(@owasp_crs/*.conf) returned no matches") + } +} diff --git a/Server/api/main_test.go b/Server/api/main_test.go index beefad31..a38ab89d 100644 --- a/Server/api/main_test.go +++ b/Server/api/main_test.go @@ -4,9 +4,15 @@ import ( "testing" "go.uber.org/goleak" + "golang.org/x/crypto/bcrypt" + + "github.com/owncord/server/auth" ) func TestMain(m *testing.M) { + // Password hashing dominates this suite's runtime at the production cost + // of 12; nothing under test depends on hash strength. + auth.SetCostForTesting(bcrypt.MinCost) goleak.VerifyTestMain(m, // Hub.Run starts long-lived goroutines that are stopped via Hub.Stop(). // API tests create routers (which start hubs) but don't always call diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 21a69843..2fdc7523 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "github.com/owncord/server/auth" @@ -27,10 +28,50 @@ const ( RoleKey ) +// sessionTouchInterval is the minimum time between last_used writes for the +// same session. last_used feeds the sessions list in account settings, where +// minute granularity is plenty — writing it on every request just serialized +// API traffic behind the single SQLite writer. +const sessionTouchInterval = 60 * time.Second + +// touchThrottleMaxEntries bounds the throttle map before stale entries are +// pruned. Entries older than sessionTouchInterval are prunable — they no +// longer suppress anything. +const touchThrottleMaxEntries = 4096 + +// touchThrottle remembers when each session hash was last touched so +// TouchSession runs at most once per sessionTouchInterval per session. +type touchThrottle struct { + mu sync.Mutex + seen map[string]time.Time +} + +// shouldTouch reports whether the session's last_used write is due, and if so +// records now as the latest touch. Stale entries are pruned opportunistically +// once the map grows past touchThrottleMaxEntries. +func (t *touchThrottle) shouldTouch(hash string, now time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + if last, ok := t.seen[hash]; ok && now.Sub(last) < sessionTouchInterval { + return false + } + if len(t.seen) >= touchThrottleMaxEntries { + cutoff := now.Add(-sessionTouchInterval) + for h, ts := range t.seen { + if ts.Before(cutoff) { + delete(t.seen, h) + } + } + } + t.seen[hash] = now + return true +} + // AuthMiddleware reads the "Authorization: Bearer " header, validates // the session, and injects the user and session into the request context. // Returns 401 if the token is missing, invalid, or the session is expired. func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { + touches := &touchThrottle{seen: make(map[string]time.Time)} return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := auth.ExtractBearerToken(r) @@ -95,12 +136,16 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { return } - // Touch last-used — non-fatal. A login session is touched inline as - // before; an API-token principal (sess == nil) is touched off the hot - // path so it never adds latency to bot/CI traffic. + // Touch last-used — non-fatal. A login session is touched inline but + // throttled to once per sessionTouchInterval per session, so hot API + // traffic doesn't queue a write per request; an API-token principal + // (sess == nil) is touched off the hot path so it never adds latency + // to bot/CI traffic. if sess != nil { - if err := database.TouchSession(r.Context(), hash); err != nil { - slog.Warn("failed to touch session", "error", err, "user_id", user.ID) + if touches.shouldTouch(hash, time.Now()) { + if err := database.TouchSession(r.Context(), hash); err != nil { + slog.Warn("failed to touch session", "error", err, "user_id", user.ID) + } } } else { touchCtx := context.WithoutCancel(r.Context()) diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index d4b08c48..9646fcb7 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -72,6 +72,61 @@ func TestAuthMiddleware_ValidToken(t *testing.T) { } } +// TestAuthMiddleware_TouchSessionThrottled verifies the last_used write is +// throttled per session: the first authenticated request touches the row, and +// an immediate second request through the same middleware instance does not — +// a hot session costs at most one write per interval instead of one per +// request. +func TestAuthMiddleware_TouchSessionThrottled(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser(context.Background(), "touchy", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + + const sentinel = "2000-01-01 00:00:00" + backdate := func() { + t.Helper() + if _, err := database.ExecContext(context.Background(), + `UPDATE sessions SET last_used = ? WHERE token = ?`, sentinel, hash); err != nil { + t.Fatalf("backdating last_used: %v", err) + } + } + lastUsed := func() string { + t.Helper() + sess, err := database.GetSessionByTokenHash(context.Background(), hash) + if err != nil || sess == nil { + t.Fatalf("GetSessionByTokenHash: %v (sess=%v)", err, sess) + } + return sess.LastUsed + } + do := func() { + t.Helper() + rr := httptest.NewRecorder() + h.ServeHTTP(rr, withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + } + + // First request: the session has never been touched by this middleware + // instance, so last_used must be written. + backdate() + do() + if lastUsed() == sentinel { + t.Fatal("first request did not touch last_used") + } + + // Second request inside the throttle interval: no write. + backdate() + do() + if lastUsed() != sentinel { + t.Error("second request within the throttle interval touched last_used; want it skipped") + } +} + func TestAuthMiddleware_MissingToken(t *testing.T) { database := newAPITestDB(t) diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index a0660336..60315d6d 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -109,6 +109,11 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + // An empty list means "nothing installed" on a server with the runtime on, + // and "you can't install anything" on a server with it off. The caller + // can't tell those apart from the body, so say which it is — otherwise the + // admin panel's empty state has to guess. + w.Header().Set("X-Plugin-Runtime", pluginRuntimeState(h.registry)) if h.store == nil { writeJSON(w, http.StatusOK, []any{}) return @@ -173,6 +178,16 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// pluginRuntimeState reports whether lifecycle calls will work, for the +// X-Plugin-Runtime response header. A nil registry means plugin support is +// compiled/configured off and every lifecycle endpoint answers 503. +func pluginRuntimeState(registry *plugin.Registry) string { + if registry == nil { + return "disabled" + } + return "enabled" +} + // isZipContentType reports whether ct looks like a zip MIME type. Both the // IANA-registered application/zip and the legacy application/x-zip-compressed // (used by some Windows clients) are accepted. The comparison is case- diff --git a/Server/api/plugins_handler_test.go b/Server/api/plugins_handler_test.go index f1e24c2e..5a0a4195 100644 --- a/Server/api/plugins_handler_test.go +++ b/Server/api/plugins_handler_test.go @@ -10,6 +10,7 @@ import ( "archive/zip" "bytes" "context" + "encoding/json" "io" "mime/multipart" "net/http" @@ -124,6 +125,58 @@ func TestPluginsHandlerInstallHappyPath(t *testing.T) { } } +// The admin panel's empty state distinguishes "no plugins installed" from +// "the runtime is off", which it can only do from this header. +func TestPluginsHandlerListReportsRuntimeState(t *testing.T) { + off := NewPluginAdminHandler(nil, nil) + rec := httptest.NewRecorder() + off.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if got := rec.Header().Get("X-Plugin-Runtime"); got != "disabled" { + t.Fatalf("nil registry: X-Plugin-Runtime = %q, want %q", got, "disabled") + } + + on := NewPluginAdminHandler(newTestPluginRegistry(t), openPluginTestDB(t)) + rec = httptest.NewRecorder() + on.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if got := rec.Header().Get("X-Plugin-Runtime"); got != "enabled" { + t.Fatalf("live registry: X-Plugin-Runtime = %q, want %q", got, "enabled") + } +} + +// The panel reads snake_case fields; without JSON tags these marshal as +// Go field names and every column renders empty. +func TestPluginsHandlerListUsesSnakeCaseJSON(t *testing.T) { + reg, mem := newTestPluginRegistryWithStore(t) + h := NewPluginAdminHandler(reg, mem) + + body, contentType := buildZipUpload(t, validPluginZip(t)) + req := httptest.NewRequest("POST", "/install", body) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("install: got %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list: got %d, want 200", rec.Code) + } + var rows []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("list body is not JSON: %v (%s)", err, rec.Body.String()) + } + if len(rows) != 1 { + t.Fatalf("expected 1 plugin row, got %d", len(rows)) + } + for _, key := range []string{"id", "name", "version", "enabled", "installed_at"} { + if _, ok := rows[0][key]; !ok { + t.Fatalf("missing %q in plugin row: %v", key, rows[0]) + } + } +} + func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing.T) { h := NewPluginAdminHandler(nil, nil) for _, tc := range []struct{ method, path string }{ @@ -189,6 +242,15 @@ func TestHasZipMagic(t *testing.T) { // ── helpers ──────────────────────────────────────────────────────────────── func newTestPluginRegistry(t *testing.T) *plugin.Registry { + t.Helper() + reg, _ := newTestPluginRegistryWithStore(t) + return reg +} + +// newTestPluginRegistryWithStore returns a registry alongside the database it +// writes to, for tests that then read the rows back through the handler. The +// two must be the same store, or the list is empty no matter what installed. +func newTestPluginRegistryWithStore(t *testing.T) (*plugin.Registry, *db.DB) { t.Helper() dir := t.TempDir() mem := openPluginTestDB(t) @@ -200,7 +262,7 @@ func newTestPluginRegistry(t *testing.T) *plugin.Registry { t.Fatalf("plugin.NewRegistry: %v", err) } t.Cleanup(func() { _ = reg.Close(context.Background()) }) - return reg + return reg, mem } // validPluginZip returns a minimal but structurally valid plugin package: diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 50aee3ab..3106bd79 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -204,7 +204,7 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http } // BUG-111: Per-user lockout to prevent password brute-force via stolen session. - lockKey := fmt.Sprintf("pw_confirm_lock:%d", user.ID) + lockKey := auth.Key("pw_confirm_lock", user.ID) if limiter.IsLockedOut(lockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", Message: "too many failed attempts, try again later", @@ -228,7 +228,7 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http } // Verify old password using constant-time bcrypt comparison. - failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) + failKey := auth.Key("pw_confirm_fail", user.ID) if !auth.CheckPassword(user.PasswordHash, req.OldPassword) { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) diff --git a/Server/api/router.go b/Server/api/router.go index bbd1622d..4222d2ec 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -53,7 +53,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Coraza WAF — opt-in via config. if cfg.Server.WAFEnabled { - r.Use(NewWAFMiddleware(cfg.Server.WAFParanoiaLevel)) + r.Use(NewWAFMiddlewareCRS(cfg.Server.WAFParanoiaLevel, cfg.Server.WAFCRSMode)) } // Health check — unauthenticated, no versioning prefix. @@ -157,8 +157,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri } else { hub.SetLiveKit(lk) - // Optionally start a companion LiveKit process. - if cfg.Voice.LiveKitBinaryPath != "" { + // Optionally start a companion LiveKit process — either from a + // configured binary or via checksum-verified auto-download (the + // download happens in the background inside Start). + if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit { proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir) if startErr := proc.Start(); startErr != nil { slog.Error("failed to start LiveKit process", "error", startErr) @@ -169,7 +171,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri } // Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs. - if lkErr == nil && cfg.Voice.LiveKitBinaryPath == "" { + if lkErr == nil && cfg.Voice.LiveKitBinaryPath == "" && !cfg.Voice.AutoDownloadLiveKit { lkHost := "" if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil { lkHost = u.Hostname() @@ -244,7 +246,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Admin panel: static files + REST API (Phase 6). // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo) - adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation) + adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, + admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg}) r.Group(func(r chi.Router) { r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)) r.Mount("/admin", adminHandler) diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index e15fd413..371c79df 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "log/slog" "net/http" @@ -66,7 +65,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - totpRateLimitKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) + totpRateLimitKey := auth.Key("totp_fail", challenge.UserID) // Atomically record this attempt and reject once the per-user failure cap // is reached. Recording up-front — rather than a read-only Check now and // Allow only on failure — closes a TOCTOU where many concurrent requests @@ -155,7 +154,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim } // BUG-111: Per-user lockout for password confirmation. - lockKey := fmt.Sprintf("pw_confirm_lock:%d", user.ID) + lockKey := auth.Key("pw_confirm_lock", user.ID) if limiter.IsLockedOut(lockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -180,7 +179,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim }) return } - failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) + failKey := auth.Key("pw_confirm_fail", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) @@ -222,7 +221,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use } // BUG-111: Per-user lockout for password confirmation. - lockKey := fmt.Sprintf("pw_confirm_lock:%d", user.ID) + lockKey := auth.Key("pw_confirm_lock", user.ID) if limiter.IsLockedOut(lockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -239,7 +238,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use }) return } - failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) + failKey := auth.Key("pw_confirm_fail", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) @@ -318,7 +317,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim } // BUG-111: Per-user lockout for password confirmation. - lockKey := fmt.Sprintf("pw_confirm_lock:%d", user.ID) + lockKey := auth.Key("pw_confirm_lock", user.ID) if limiter.IsLockedOut(lockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -335,7 +334,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim }) return } - failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) + failKey := auth.Key("pw_confirm_fail", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index b27648cf..41145c3c 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -114,7 +114,7 @@ func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLim // BUG-131: Per-user upload rate limit to prevent disk exhaustion. user, ok := r.Context().Value(UserKey).(*db.User) if ok && user != nil { - uploadKey := fmt.Sprintf("upload:%d", user.ID) + uploadKey := auth.Key("upload", user.ID) if !limiter.Allow(uploadKey, uploadRateLimitPerMinute, time.Minute) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", diff --git a/Server/api/waf.go b/Server/api/waf.go index a21ac3c4..b4766b34 100644 --- a/Server/api/waf.go +++ b/Server/api/waf.go @@ -2,6 +2,7 @@ // // waf.go implements Coraza WAF middleware for OWASP CRS protection. // Toggle via config: server.waf_enabled (default: false). +// The OWASP Core Rule Set layer mode is server.waf_crs_mode (default: detect). package api import ( @@ -10,17 +11,204 @@ import ( "log/slog" "net/http" + coreruleset "github.com/corazawaf/coraza-coreruleset/v4" "github.com/corazawaf/coraza/v3" "github.com/corazawaf/coraza/v3/types" ) +// CRS layer modes (server.waf_crs_mode). The CRS engine runs alongside the +// long-standing inline rules, which keep their blocking behavior in every mode. +const ( + // CRSModeOff disables the OWASP CRS layer entirely. + CRSModeOff = "off" + // CRSModeDetect evaluates the full OWASP CRS and logs matches without + // ever blocking a request (SecRuleEngine DetectionOnly). + CRSModeDetect = "detect" + // CRSModeBlock evaluates the full OWASP CRS in anomaly-scoring blocking + // mode. Only enable after reviewing detect-mode logs against real traffic. + CRSModeBlock = "block" +) + +// normalizeCRSMode maps a config string to a known CRS mode. Empty and +// unknown values fall back to detect so a typo never silently disables the +// CRS layer (and never accidentally enables blocking either). +func normalizeCRSMode(mode string) string { + switch mode { + case CRSModeOff, CRSModeDetect, CRSModeBlock: + return mode + case "": + return CRSModeDetect + default: + slog.Warn("waf: unknown server.waf_crs_mode, falling back to detect", + "mode", mode) + return CRSModeDetect + } +} + +// newCRSWAF builds a second Coraza engine loaded with the embedded OWASP Core +// Rule Set (github.com/corazawaf/coraza-coreruleset/v4). It is kept separate +// from the inline-rules engine so the inline rules keep their exact, +// test-pinned blocking behavior regardless of the CRS mode. +// +// Rationale for defaulting to detection-only: OwnCord is a chat server, and +// chat messages routinely contain SQL-ish and HTML-ish text that the CRS is +// prone to false-positive on. Blocking mode on a chat API needs tuning +// against real traffic first; detect mode gives the operator full CRS +// visibility (every match is logged) with zero user-facing risk. +func newCRSWAF(paranoiaLevel int, block bool, onMatch func(types.MatchedRule)) (coraza.WAF, error) { + engine := "DetectionOnly" + if block { + engine = "On" + } + return coraza.NewWAF( + coraza.NewWAFConfig(). + WithRootFS(slashFS{coreruleset.FS}). + WithErrorCallback(onMatch). + WithDirectives(fmt.Sprintf(` + Include @coraza.conf-recommended + Include @crs-setup.conf.example + + # Paranoia level (mirrors the inline engine; CRS rule 901120 + # only defaults this if unset, so it must be set before the + # rule files are included). + SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=%d" + + # Allowed HTTP methods (CRS rule 911100). The CRS default is + # "GET HEAD POST OPTIONS", but this REST API also serves + # PUT/PATCH/DELETE routes (profile updates, blocks, pins, + # channel management), so those must be allowed or every such + # request scores anomaly 5 (= instant block at the default + # threshold). id 900200 is the canonical crs-setup id for + # this setting. + SecAction "id:900200,phase:1,pass,t:none,nolog,setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'" + + # Exclude the file upload endpoint from CRS body inspection + # (binary multipart content up to upload.max_size_mb; the + # inline engine excludes it the same way). Rule 920420 + # ("Request content type is not allowed by policy", anomaly + # score 5) is also removed for this route: uploads + # legitimately post binary content types (e.g. + # application/octet-stream) that the CRS default policy + # rejects. Local rule ids 1-99999 are reserved for us by the + # CRS numbering scheme. + SecRule REQUEST_URI "@beginsWith /api/v1/uploads" "id:1001,phase:1,pass,nolog,ctl:requestBodyAccess=Off,ctl:ruleRemoveById=920420" + + Include @owasp_crs/*.conf + + # Engine mode: DetectionOnly logs matches without interrupting; + # On enforces CRS anomaly-scoring blocking. + SecRuleEngine %s + + # We never feed response data into this engine (parity with the + # inline engine), so don't pay for response body buffering. + SecResponseBodyAccess Off + + # Body limits: match the app's 1 MiB non-upload cap (see + # MaxBodySizeUnless / config.MaxMessageBytes) instead of the + # recommended-config 12.5 MiB, and never reject on size — + # request size enforcement belongs to the app middleware, not + # the CRS layer. Uploads are excluded from body access above. + SecRequestBodyLimit 1048576 + SecRequestBodyLimitAction ProcessPartial + + # Match logging goes through the error callback into slog; + # don't also emit native audit log records. + SecAuditEngine Off + `, paranoiaLevel, engine)), + ) +} + +// logCRSMatch is the per-rule CRS match logger. It is used for the block-mode +// default (blocked requests are rare and their per-rule detail is wanted) and +// whenever a caller supplies it explicitly (tests). The default detect-mode +// path does NOT use it — see logCRSMatchesAggregate. +func logCRSMatch(mr types.MatchedRule) { + slog.Warn("waf: CRS rule matched", + "rule_id", mr.Rule().ID(), + "severity", mr.Rule().Severity().String(), + "uri", mr.URI(), + "msg", mr.Message(), + "data", mr.Data(), + ) +} + +// logCRSMatchesAggregate emits at most ONE log line for a request that tripped +// CRS detection rules, instead of one Warn per matched rule. In the default +// detect mode ordinary chat prose routinely trips several CRS SQLi/XSS rules +// per request (see TestWAFMiddleware_CRSBlockMode_FalsePositivesOnSQLishChatProse) +// and anomaly scoring amplifies the count, so per-rule Warn logging on the +// request goroutine is pure hot-path overhead (allocation + serialized log +// I/O + log-volume amplification). This keeps the signal — how many rules +// matched and the highest-severity one — on a single Warn and moves the full +// rule-id list to Debug. It reads only per-request transaction state (no +// shared/global state, no locks). +func logCRSMatchesAggregate(tx types.Transaction) { + if tx == nil { + return + } + matched := tx.MatchedRules() + ids := make([]int, 0, len(matched)) + var ( + topRuleID int + topSev types.RuleSeverity + topURI string + topMsg string + ) + for _, mr := range matched { + // Internal bookkeeping rules (the setvar/ctl SecActions this package + // installs, and CRS setup actions) carry no message and are not + // detections; the per-rule callback skips them too (it only fires for + // rules with logging enabled), so keep them out of the count. + if mr.Message() == "" { + continue + } + ids = append(ids, mr.Rule().ID()) + // Severity is inverted: 0 (emergency) is the most severe, 7 (debug) + // the least. The first detection seeds the max; smaller wins after. + if sev := mr.Rule().Severity(); len(ids) == 1 || sev < topSev { + topSev = sev + topRuleID = mr.Rule().ID() + topURI = mr.URI() + topMsg = mr.Message() + } + } + if len(ids) == 0 { + return + } + slog.Warn("waf: CRS detect-mode matches", + "matches", len(ids), + "top_rule_id", topRuleID, + "top_severity", topSev.String(), + "uri", topURI, + "top_msg", topMsg, + ) + 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. +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. +func newWAFMiddleware(paranoiaLevel int, crsMode string, onCRSMatch func(types.MatchedRule)) func(http.Handler) http.Handler { if paranoiaLevel < 1 || paranoiaLevel > 4 { paranoiaLevel = 2 } + crsMode = normalizeCRSMode(crsMode) waf, err := coraza.NewWAF( coraza.NewWAFConfig(). @@ -66,7 +254,41 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } } - slog.Info("waf: Coraza WAF enabled", "paranoia_level", paranoiaLevel) + // OWASP CRS layer — a second engine so the inline rules above keep their + // exact blocking behavior in every CRS mode. If the CRS fails to load the + // server continues with the inline engine only (same failure philosophy + // as above). + // + // aggregateCRSLog collapses detect-mode match logging to one line per + // request (logCRSMatchesAggregate) instead of one Warn per matched rule. + // It applies ONLY to the default detect-mode path — the hot path for + // ordinary traffic. When a caller supplies its own onCRSMatch (tests) the + // per-rule callback is wired so every match stays observable; in block + // mode the per-rule logCRSMatch is kept (blocked requests are rare and the + // per-rule detail is wanted), leaving block-mode behavior exactly as-is. + aggregateCRSLog := false + var crsWAF coraza.WAF + if crsMode != CRSModeOff { + crsCallback := onCRSMatch + if crsCallback == nil { + if crsMode == CRSModeDetect { + // Default detect mode: leave the engine error callback nil so + // nothing logs per rule on the request goroutine, and instead + // aggregate the transaction's matches after processing. + aggregateCRSLog = true + } else { + crsCallback = logCRSMatch + } + } + cw, crsErr := newCRSWAF(paranoiaLevel, crsMode == CRSModeBlock, crsCallback) + if crsErr != nil { + slog.Error("waf: failed to load OWASP CRS, continuing with inline rules only", "error", crsErr) + } else { + crsWAF = cw + } + } + + slog.Info("waf: Coraza WAF enabled", "paranoia_level", paranoiaLevel, "crs_mode", crsMode) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -78,6 +300,23 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { } }() + var crsTx types.Transaction + if crsWAF != nil { + crsTx = crsWAF.NewTransaction() + defer func() { + // One aggregated match log per request (detect-mode + // default only); reads per-request transaction state, so + // it must run before the transaction is closed. + if aggregateCRSLog { + logCRSMatchesAggregate(crsTx) + } + crsTx.ProcessLogging() + if err := crsTx.Close(); err != nil { + slog.Debug("waf: error closing CRS transaction", "error", err) + } + }() + } + // Process request headers tx.ProcessConnection(r.RemoteAddr, 0, "", 0) tx.ProcessURI(r.URL.String(), r.Method, r.Proto) @@ -92,13 +331,44 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { return } + // CRS phase 1. In detect mode the engine never interrupts, so the + // returned interruption is only non-nil in block mode. + if crsTx != nil { + crsTx.ProcessConnection(r.RemoteAddr, 0, "", 0) + crsTx.ProcessURI(r.URL.String(), r.Method, r.Proto) + for name, values := range r.Header { + for _, value := range values { + crsTx.AddRequestHeader(name, value) + } + } + // net/http promotes Host and Transfer-Encoding out of + // r.Header; re-add them like the official coraza http + // connector does, otherwise CRS rule 920280 ("Request + // Missing a Host Header", anomaly score 5) fires on every + // request. The inline engine is left as-is on purpose — its + // rules never look at these headers and its behavior is + // pinned by tests. + if r.Host != "" { + crsTx.AddRequestHeader("Host", r.Host) + crsTx.SetServerName(r.Host) + } + for _, te := range r.TransferEncoding { + crsTx.AddRequestHeader("Transfer-Encoding", te) + } + if it := crsTx.ProcessRequestHeaders(); it != nil { + handleWAFInterruption(w, it) + return + } + } + // Process request body (if applicable). Use ContentLength != 0 so // chunked requests (Transfer-Encoding: chunked → ContentLength == -1) // are inspected too; otherwise the SQLi/XSS/RCE body rules are silently // skipped for them. The read is bounded by SecRequestBodyLimit inside // Coraza. ContentLength == 0 (no body) still skips inspection. if r.Body != nil && r.ContentLength != 0 { - if it, _, err := tx.ReadRequestBodyFrom(r.Body); it != nil { + it, written, err := tx.ReadRequestBodyFrom(r.Body) + if it != nil { handleWAFInterruption(w, it) return } else if err != nil { @@ -112,10 +382,46 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { slog.Debug("waf: error processing request body", "error", err) } - // Replace body with buffered version so downstream handlers can read it - reader, err := tx.RequestBodyReader() - if err == nil && reader != nil { - r.Body = io.NopCloser(reader) + // Feed the CRS engine from the inline engine's buffer so the + // body is only read from the wire once. written == 0 means the + // inline engine skipped buffering (requestBodyAccess turned + // off for this route, e.g. uploads) — the CRS engine excludes + // those routes too, so skip it as well and leave r.Body alone. + if written > 0 { + if crsTx != nil { + if reader, err := tx.RequestBodyReader(); err == nil && reader != nil { + if it, _, err := crsTx.ReadRequestBodyFrom(reader); it != nil { + handleWAFInterruption(w, it) + return + } else if err != nil { + slog.Debug("waf: error reading CRS request body", "error", err) + } + } + } + + // Replace body with buffered version so downstream handlers + // can read it. Only done when the inline engine actually + // buffered the body — replacing unconditionally would hand + // routes with body inspection disabled (uploads) an empty + // reader instead of the original stream. + reader, err := tx.RequestBodyReader() + if err == nil && reader != nil { + r.Body = io.NopCloser(reader) + } + } + } + + // CRS phase 2 always runs, even without a body: CRS request rules + // (including query-string XSS/SQLi and the anomaly-blocking + // evaluation) are phase 2 rules. The inline engine deliberately + // keeps its original behavior of only running phase 2 when a body + // is present. + if crsTx != nil { + if it, err := crsTx.ProcessRequestBody(); it != nil { + handleWAFInterruption(w, it) + return + } else if err != nil { + slog.Debug("waf: error processing CRS request body", "error", err) } } diff --git a/Server/api/waf_crs_test.go b/Server/api/waf_crs_test.go new file mode 100644 index 00000000..6dbd7a8d --- /dev/null +++ b/Server/api/waf_crs_test.go @@ -0,0 +1,385 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/corazawaf/coraza/v3/types" +) + +// captureSlog redirects the default slog logger to a buffer for the duration +// of fn and returns everything it wrote (Debug and up). +func captureSlog(t *testing.T, fn func()) string { + t.Helper() + var buf strings.Builder + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + fn() + return buf.String() +} + +// matchRecorder captures CRS rule matches reported through the error +// callback, which is how detect-mode (DetectionOnly) matches surface. +type matchRecorder struct { + mu sync.Mutex + ruleIDs []int +} + +func (m *matchRecorder) record(mr types.MatchedRule) { + m.mu.Lock() + defer m.mu.Unlock() + m.ruleIDs = append(m.ruleIDs, mr.Rule().ID()) +} + +func (m *matchRecorder) matchedInRange(lo, hi int) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, id := range m.ruleIDs { + if id >= lo && id <= hi { + return true + } + } + return false +} + +func (m *matchRecorder) count() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.ruleIDs) +} + +func TestNewCRSWAF_LoadsCoreRuleSet(t *testing.T) { + for _, block := range []bool{false, true} { + if _, err := newCRSWAF(2, block, func(types.MatchedRule) {}); err != nil { + t.Fatalf("newCRSWAF(block=%v): %v", block, err) + } + } +} + +func TestNormalizeCRSMode(t *testing.T) { + cases := map[string]string{ + "off": CRSModeOff, + "detect": CRSModeDetect, + "block": CRSModeBlock, + "": CRSModeDetect, + "bogus": CRSModeDetect, + "DETECT": CRSModeDetect, // not an exact known value → detect fallback + } + for in, want := range cases { + if got := normalizeCRSMode(in); got != want { + t.Errorf("normalizeCRSMode(%q) = %q, want %q", in, got, want) + } + } +} + +// Detect mode: a classic XSS probe in the query string must be detected by +// the CRS (rule ids 941xxx) but must NOT be blocked — the request reaches the +// downstream handler untouched. +func TestWAFMiddleware_CRSDetectMode_DetectsXSSProbeWithoutBlocking(t *testing.T) { + rec := &matchRecorder{} + middleware := newWAFMiddleware(2, CRSModeDetect, rec.record) + + called := false + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("detect mode must never block: downstream handler was not called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + if !rec.matchedInRange(941000, 941999) { + t.Fatalf("expected a CRS XSS rule (941xxx) match, got rule ids %v", rec.ruleIDs) + } +} + +// Detect mode: a path traversal probe is detected (930xxx LFI rules) without +// being blocked by the CRS layer. Note the inline engine's own traversal rule +// (930100) is phase 2 and the inline engine only runs phase 2 when a body is +// present, so a bodyless GET is not blocked by it either — pinned behavior. +func TestWAFMiddleware_CRSDetectMode_DetectsPathTraversalProbe(t *testing.T) { + rec := &matchRecorder{} + middleware := newWAFMiddleware(2, CRSModeDetect, rec.record) + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/files?name=..%2f..%2f..%2fetc%2fpasswd", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + if !rec.matchedInRange(930000, 930999) { + t.Fatalf("expected a CRS LFI rule (930xxx) match, got rule ids %v", rec.ruleIDs) + } +} + +// The default detect-mode path (no caller-supplied callback) must not log one +// line per matched CRS rule on the request goroutine. An XSS probe trips +// several 941xxx rules plus anomaly scoring; the middleware must collapse them +// into a single aggregated Warn and never emit the per-rule "CRS rule matched" +// line. Only the request itself is wrapped in the log capture so the one-time +// "WAF enabled" startup log doesn't count. +func TestWAFMiddleware_CRSDetectMode_AggregatesMatchLoggingPerRequest(t *testing.T) { + middleware := NewWAFMiddlewareCRS(2, CRSModeDetect) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + out := captureSlog(t, func() { + handler.ServeHTTP(rr, req) + }) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + // Exactly one aggregated match line for the request... + if n := strings.Count(out, "waf: CRS detect-mode matches"); n != 1 { + t.Fatalf("want exactly 1 aggregated CRS match log, got %d\nlogs:\n%s", n, out) + } + // ...carrying the retained signal (count + highest-severity rule id)... + if !strings.Contains(out, "matches=") || !strings.Contains(out, "top_rule_id=") { + t.Fatalf("aggregated log missing count/top_rule_id signal:\n%s", out) + } + // ...and the per-rule hot-path logger must not fire in this default path. + if strings.Contains(out, "waf: CRS rule matched") { + t.Fatalf("per-rule CRS logging must not fire in default detect mode:\n%s", out) + } +} + +// A caller-supplied callback (as tests use) keeps the per-rule callback wired +// and turns aggregation off, so every match stays observable. This pins that +// the aggregation change did not disturb the callback seam. +func TestWAFMiddleware_CRSDetectMode_CustomCallbackStillPerRule(t *testing.T) { + rec := &matchRecorder{} + middleware := newWAFMiddleware(2, CRSModeDetect, rec.record) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + out := captureSlog(t, func() { + handler.ServeHTTP(rr, req) + }) + + if !rec.matchedInRange(941000, 941999) { + t.Fatalf("custom callback must still see per-rule matches, got %v", rec.ruleIDs) + } + // With a custom callback wired, the default aggregation path is off. + if strings.Contains(out, "waf: CRS detect-mode matches") { + t.Fatalf("aggregation must be off when a callback is supplied:\n%s", out) + } +} + +// A benign chat message containing SQL-ish prose in a JSON body must pass in +// detect mode and remain readable downstream (chat traffic is exactly what +// the detect-mode default protects from CRS false positives). +func TestWAFMiddleware_CRSDetectMode_AllowsBenignSQLishChatMessage(t *testing.T) { + const requestBody = `{"content":"you can just select the option from the users menu where it says settings"}` + rec := &matchRecorder{} + middleware := newWAFMiddleware(2, CRSModeDetect, rec.record) + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(body) != requestBody { + t.Fatalf("body = %q, want %q", string(body), requestBody) + } + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OwnCordClient/1.0") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } +} + +// Block mode: the CRS anomaly-scoring evaluation must interrupt a clear +// attack probe. +func TestWAFMiddleware_CRSBlockMode_BlocksXSSProbe(t *testing.T) { + middleware := NewWAFMiddlewareCRS(2, CRSModeBlock) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("downstream handler should not be called for blocked CRS request") + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", rr.Code, rr.Body.String()) + } + if strings.TrimSpace(rr.Body.String()) != `{"error":"request blocked by security rules"}` { + t.Fatalf("body = %q, want blocked JSON", rr.Body.String()) + } +} + +// Block mode lets an ordinary benign JSON request through. +func TestWAFMiddleware_CRSBlockMode_AllowsBenignJSONRequest(t *testing.T) { + const requestBody = `{"status":"hello there, having a great day"}` + middleware := NewWAFMiddlewareCRS(2, CRSModeBlock) + + called := false + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/me", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OwnCordClient/1.0") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("expected downstream handler to be called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } +} + +// Pins the reason the CRS layer defaults to detect rather than block: CRS +// SQLi rules (942200/942260/942480 at the default threshold) false-positive +// on benign SQL-ish chat prose in a JSON body and block mode rejects it. +// Operators enabling block mode are expected to tune exclusions against +// their real traffic first (detect-mode logs show exactly which rules fire). +// If this test ever starts failing because the request is no longer blocked, +// a CRS update fixed the false positive — reconsider the default then. +func TestWAFMiddleware_CRSBlockMode_FalsePositivesOnSQLishChatProse(t *testing.T) { + const requestBody = `{"content":"you can just select the option from the users menu where it says settings"}` + middleware := NewWAFMiddlewareCRS(2, CRSModeBlock) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("downstream handler should not be called: CRS block mode is expected to FP on this prose") + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OwnCordClient/1.0") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (documented CRS false positive); body = %s", rr.Code, rr.Body.String()) + } +} + +// Off mode: the CRS layer is not evaluated at all (no matches recorded), and +// requests the inline rules allow still pass. +func TestWAFMiddleware_CRSOffMode_SkipsCRSEntirely(t *testing.T) { + rec := &matchRecorder{} + middleware := newWAFMiddleware(2, CRSModeOff, rec.record) + + called := false + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("expected downstream handler to be called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rr.Code) + } + if rec.count() != 0 { + t.Fatalf("CRS must not run in off mode, got %d matches: %v", rec.count(), rec.ruleIDs) + } +} + +// The inline engine keeps blocking regardless of CRS mode (its behavior is +// pinned by waf_test.go; this pins it per-mode). +func TestWAFMiddleware_InlineRulesStillBlockInEveryCRSMode(t *testing.T) { + for _, mode := range []string{CRSModeOff, CRSModeDetect, CRSModeBlock} { + t.Run(mode, func(t *testing.T) { + middleware := NewWAFMiddlewareCRS(2, mode) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("downstream handler should not be called for blocked scanner request") + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels", nil) + req.Header.Set("User-Agent", "sqlmap/1.8") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", rr.Code, rr.Body.String()) + } + }) + } +} + +// Upload requests keep their original body stream in every mode: both engines +// turn requestBodyAccess off for /api/v1/uploads, and the middleware must not +// swap the body for an (empty) buffered reader. +func TestWAFMiddleware_UploadBodyNotBufferedOrEmptied(t *testing.T) { + const requestBody = "binary-ish upload payload \x00\x01\x02" + for _, mode := range []string{CRSModeOff, CRSModeDetect, CRSModeBlock} { + t.Run(mode, func(t *testing.T) { + middleware := NewWAFMiddlewareCRS(2, mode) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(body) != requestBody { + t.Fatalf("body = %q, want %q", string(body), requestBody) + } + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("User-Agent", "OwnCordClient/1.0") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + }) + } +} diff --git a/Server/auth/main_test.go b/Server/auth/main_test.go index afb80b6f..2c1a009c 100644 --- a/Server/auth/main_test.go +++ b/Server/auth/main_test.go @@ -4,8 +4,14 @@ import ( "testing" "go.uber.org/goleak" + "golang.org/x/crypto/bcrypt" + + "github.com/owncord/server/auth" ) func TestMain(m *testing.M) { + // Password hashing dominates this suite's runtime at the production cost + // of 12; nothing under test depends on hash strength. + auth.SetCostForTesting(bcrypt.MinCost) goleak.VerifyTestMain(m) } diff --git a/Server/auth/password.go b/Server/auth/password.go index e1820343..d9657c30 100644 --- a/Server/auth/password.go +++ b/Server/auth/password.go @@ -8,11 +8,25 @@ import ( ) const ( - bcryptCost = 12 minPassLen = 8 maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes ) +// bcryptCost is a var (not a const) so SetCostForTesting can lower it in test +// binaries. Production code never mutates it. +var bcryptCost = 12 + +// SetCostForTesting lowers the bcrypt cost for the current process and resets +// the dummy timing pad so it is regenerated at the new cost. Intended to be +// called from TestMain with bcrypt.MinCost: password hashing dominates the +// api/admin test suites (~264 cost-12 hashes ≈ minutes of pure bcrypt), and +// nothing about the tests depends on the hash strength. +func SetCostForTesting(cost int) { + bcryptCost = cost + dummyHashOnce = sync.Once{} + dummyHash = nil +} + // ErrPasswordTooShort is returned when the password is below the minimum length. var ErrPasswordTooShort = errors.New("password must be at least 8 characters") diff --git a/Server/auth/ratelimit.go b/Server/auth/ratelimit.go index bfa7d11b..0962bd36 100644 --- a/Server/auth/ratelimit.go +++ b/Server/auth/ratelimit.go @@ -2,6 +2,7 @@ package auth import ( "context" + "strconv" "time" "github.com/owncord/server/syncutil" @@ -28,70 +29,112 @@ type LockoutPersister interface { LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error) } +// rateLimiterShards is the number of independently locked buckets the key +// space is split across. Must be a power of two (shardFor masks with -1). +const rateLimiterShards = 32 + +// rateLimiterShard holds one bucket's windows/lockouts maps under its own +// mutex, so contention on one key never serializes unrelated keys. +type rateLimiterShard struct { + mu syncutil.Mutex + windows map[string]*entry + lockouts map[string]*lockoutEntry +} + // RateLimiter is an in-memory, thread-safe sliding-window rate limiter with // optional IP lockout support. When a LockoutStore is provided, lockout // entries are persisted so they survive server restarts. // +// Internally the key space is sharded across 32 buckets (FNV-1a of the key), +// each with its own mutex, so the process-wide limiter is no longer a single +// lock every WS message and HTTP request funnels through. +// // NOTE (L2): The sliding-window counters and the PartialAuthStore / // UsedTOTPCodeStore (in totp.go) are process-local. The server must run // as a single instance. Horizontal scaling requires migrating these // stores to a shared backend (e.g. Redis). type RateLimiter struct { - mu syncutil.Mutex - windows map[string]*entry - lockouts map[string]*lockoutEntry - store LockoutPersister // nil = pure in-memory (tests, non-login limiters) + shards [rateLimiterShards]rateLimiterShard + store LockoutPersister // nil = pure in-memory (tests, non-login limiters) +} + +// newRateLimiter allocates the per-shard maps shared by both constructors. +func newRateLimiter(store LockoutPersister) *RateLimiter { + rl := &RateLimiter{store: store} + for i := range rl.shards { + rl.shards[i].windows = make(map[string]*entry) + rl.shards[i].lockouts = make(map[string]*lockoutEntry) + } + return rl } // NewRateLimiter returns an initialised RateLimiter with no persistence. func NewRateLimiter() *RateLimiter { - return &RateLimiter{ - windows: make(map[string]*entry), - lockouts: make(map[string]*lockoutEntry), - } + return newRateLimiter(nil) } // NewPersistentRateLimiter returns a RateLimiter that persists lockouts via // the provided store. It loads any active lockouts from the store on creation. func NewPersistentRateLimiter(store LockoutPersister) *RateLimiter { - rl := &RateLimiter{ - windows: make(map[string]*entry), - lockouts: make(map[string]*lockoutEntry), - store: store, - } + rl := newRateLimiter(store) // Load surviving lockouts from the store. Constructor runs at startup // with no request in flight, so background context. if keys, expiresAt, err := store.LoadActiveLockouts(context.Background()); err == nil { for i, key := range keys { - rl.lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]} + rl.shardFor(key).lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]} } } return rl } +// shardFor maps key to its bucket via FNV-1a (inlined so hashing allocates +// nothing, unlike hash/fnv's digest). +func (r *RateLimiter) shardFor(key string) *rateLimiterShard { + h := uint32(2166136261) + for i := 0; i < len(key); i++ { + h ^= uint32(key[i]) + h *= 16777619 + } + return &r.shards[h&(rateLimiterShards-1)] +} + +// Key builds the canonical "prefix:id" rate-limit key. It exists because the +// hot paths (every WS message, every authenticated request) used to pay for a +// fmt.Sprintf per call; strconv.AppendInt into a pre-sized buffer leaves the +// string itself as the only allocation. Compose multi-part keys by nesting: +// Key(Key("voice_e2ee_offer", userID), channelID). +func Key(prefix string, id int64) string { + b := make([]byte, 0, len(prefix)+21) // ':' + up to 20 digits/sign + b = append(b, prefix...) + b = append(b, ':') + b = strconv.AppendInt(b, id, 10) + return string(b) +} + // Allow reports whether a request from key is permitted given the limit and // window. It records the current request timestamp only when the request is // permitted. Returns false when key is locked out or has exceeded limit within // window. func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { - r.mu.Lock() - defer r.mu.Unlock() + s := r.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() // Lockout takes priority. - if lo, ok := r.lockouts[key]; ok { + if lo, ok := s.lockouts[key]; ok { if time.Now().Before(lo.expiresAt) { return false } - delete(r.lockouts, key) + delete(s.lockouts, key) } now := time.Now() cutoff := now.Add(-window) - e, ok := r.windows[key] + e, ok := s.windows[key] if !ok { e = &entry{} - r.windows[key] = e + s.windows[key] = e } // Prune timestamps outside the current window. @@ -117,10 +160,11 @@ func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { // once the lockout is decided, so the caller's cancellation is detached // (WithoutCancel) rather than aborting the write mid-request. func (r *RateLimiter) Lockout(ctx context.Context, key string, duration time.Duration) { - r.mu.Lock() - defer r.mu.Unlock() + s := r.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() expiresAt := time.Now().Add(duration) - r.lockouts[key] = &lockoutEntry{expiresAt: expiresAt} + s.lockouts[key] = &lockoutEntry{expiresAt: expiresAt} if r.store != nil { _ = r.store.UpsertLockout(context.WithoutCancel(ctx), key, expiresAt) } @@ -128,16 +172,17 @@ func (r *RateLimiter) Lockout(ctx context.Context, key string, duration time.Dur // IsLockedOut reports whether key is currently under a lockout. func (r *RateLimiter) IsLockedOut(key string) bool { - r.mu.Lock() - defer r.mu.Unlock() - lo, ok := r.lockouts[key] + s := r.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() + lo, ok := s.lockouts[key] if !ok { return false } if time.Now().Before(lo.expiresAt) { return true } - delete(r.lockouts, key) + delete(s.lockouts, key) return false } @@ -146,19 +191,20 @@ func (r *RateLimiter) IsLockedOut(key string) bool { // rate-limit checks where the caller wants to record (via Allow) only on // specific outcomes such as verification failures. func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool { - r.mu.Lock() - defer r.mu.Unlock() + s := r.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() - if lo, ok := r.lockouts[key]; ok { + if lo, ok := s.lockouts[key]; ok { if time.Now().Before(lo.expiresAt) { return false } - delete(r.lockouts, key) + delete(s.lockouts, key) } cutoff := time.Now().Add(-window) - e, ok := r.windows[key] + e, ok := s.windows[key] if !ok { return true } @@ -176,10 +222,11 @@ func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool { // Reset clears all rate-limit state (timestamps and lockout) for key. // Like Lockout, the store delete must complete once decided (WithoutCancel). func (r *RateLimiter) Reset(ctx context.Context, key string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.windows, key) - delete(r.lockouts, key) + s := r.shardFor(key) + s.mu.Lock() + defer s.mu.Unlock() + delete(s.windows, key) + delete(s.lockouts, key) if r.store != nil { _ = r.store.DeleteLockout(context.WithoutCancel(ctx), key) } @@ -193,32 +240,39 @@ func (r *RateLimiter) Reset(ctx context.Context, key string) { // // A lockouts entry is removed when its expiry has passed. // +// Shards are swept one at a time, so the periodic cleanup never stalls the +// whole limiter at once. +// // Pass defaultCleanupMaxWindow (15 minutes) for normal server operation, or // a shorter duration in tests. func (r *RateLimiter) Cleanup(maxWindow time.Duration) { - r.mu.Lock() - defer r.mu.Unlock() - cutoff := time.Now().Add(-maxWindow) - for key, e := range r.windows { - allStale := true - for _, ts := range e.timestamps { - if ts.After(cutoff) { - allStale = false - break + for i := range r.shards { + s := &r.shards[i] + s.mu.Lock() + + for key, e := range s.windows { + allStale := true + for _, ts := range e.timestamps { + if ts.After(cutoff) { + allStale = false + break + } + } + if allStale { + delete(s.windows, key) } } - if allStale { - delete(r.windows, key) - } - } - now := time.Now() - for key, lo := range r.lockouts { - if now.After(lo.expiresAt) { - delete(r.lockouts, key) + now := time.Now() + for key, lo := range s.lockouts { + if now.After(lo.expiresAt) { + delete(s.lockouts, key) + } } + + s.mu.Unlock() } if r.store != nil { @@ -249,9 +303,15 @@ func (r *RateLimiter) StartCleanup(interval, maxWindow time.Duration, stop <-cha } // Len returns the number of entries currently stored in the windows and -// lockouts maps. It is primarily useful for testing and monitoring. +// lockouts maps, summed across all shards. It is primarily useful for +// testing and monitoring. func (r *RateLimiter) Len() (windows, lockouts int) { - r.mu.Lock() - defer r.mu.Unlock() - return len(r.windows), len(r.lockouts) + for i := range r.shards { + s := &r.shards[i] + s.mu.Lock() + windows += len(s.windows) + lockouts += len(s.lockouts) + s.mu.Unlock() + } + return windows, lockouts } diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go index 7cd1f92e..843af8aa 100644 --- a/Server/auth/ratelimit_test.go +++ b/Server/auth/ratelimit_test.go @@ -231,3 +231,46 @@ func TestRateLimiter_ResetClearsLockout(t *testing.T) { t.Error("Reset() should clear lockout, but key is still locked out") } } + +func TestKey_MatchesSprintfShape(t *testing.T) { + cases := []struct { + prefix string + id int64 + want string + }{ + {"ping", 42, "ping:42"}, + {"voice_join", 0, "voice_join:0"}, + {"login", -7, "login:-7"}, + {"session", 9223372036854775807, "session:9223372036854775807"}, + } + for _, c := range cases { + if got := auth.Key(c.prefix, c.id); got != c.want { + t.Errorf("Key(%q, %d) = %q, want %q", c.prefix, c.id, got, c.want) + } + } + // Multi-part keys compose by nesting, matching the old "%d:%d" shape. + if got := auth.Key(auth.Key("voice_e2ee_offer", 3), 15); got != "voice_e2ee_offer:3:15" { + t.Errorf("nested Key = %q, want voice_e2ee_offer:3:15", got) + } +} + +// TestRateLimiter_LenSumsAcrossShards pins the sharded rewrite: keys that hash +// to different buckets must all be visible through Len and evictable through +// Cleanup, exactly as with the old single-map limiter. +func TestRateLimiter_LenSumsAcrossShards(t *testing.T) { + rl := auth.NewRateLimiter() + const n = 100 // enough distinct keys to populate many of the 32 shards + for i := range n { + if !rl.Allow(auth.Key("shardspread", int64(i)), 1, time.Minute) { + t.Fatalf("Allow for fresh key %d = false, want true", i) + } + } + if wins, _ := rl.Len(); wins != n { + t.Fatalf("Len().windows = %d, want %d", wins, n) + } + time.Sleep(15 * time.Millisecond) + rl.Cleanup(10 * time.Millisecond) + if wins, _ := rl.Len(); wins != 0 { + t.Errorf("Len().windows = %d after Cleanup, want 0 across all shards", wins) + } +} diff --git a/Server/config/config.go b/Server/config/config.go index 8a1419fe..c8429f67 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -33,11 +33,11 @@ type Config struct { Logging LoggingConfig `koanf:"logging"` } -// LoggingConfig controls server log verbosity. The in-memory ring buffer that -// backs the admin panel's live log view always captures DEBUG regardless of -// this setting — Level only gates what is written to stdout. +// LoggingConfig controls server log verbosity. Level gates both stdout and +// the in-memory ring buffer that backs the admin panel's live log view, so +// suppressed levels cost nothing anywhere on the hot path. type LoggingConfig struct { - // Level is the minimum level written to stdout: "debug" | "info" | "warn" | + // Level is the minimum level logged: "debug" | "info" | "warn" | // "error". Override at runtime without editing config.yaml via the // OWNCORD_LOGGING_LEVEL environment variable. Level string `koanf:"level"` @@ -137,7 +137,17 @@ type VoiceConfig struct { LiveKitAPISecret string `koanf:"livekit_api_secret"` // LiveKit API secret LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start - NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect + // AutoDownloadLiveKit downloads a pinned, checksum-verified livekit-server + // release from the official LiveKit GitHub releases into + // /livekit/ and runs it as the companion process, when no + // livekit_binary is configured. Fresh installs enable this in the + // generated config.yaml so voice works out of the box; the compiled-in + // default stays false so existing configs keep their behaviour. + AutoDownloadLiveKit bool `koanf:"auto_download_livekit"` + // LiveKitVersion overrides the pinned livekit-server release version used + // by auto-download (e.g. "1.13.5"). Empty = the built-in pin. + LiveKitVersion string `koanf:"livekit_version"` + NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect // AdvertiseInternalIP makes LiveKit advertise internal (LAN) host candidates // in addition to the external node_ip mapping, so clients on the local // network can connect while remote clients use the public IP. @@ -155,6 +165,13 @@ type ServerConfig struct { AdminAllowedCIDRs []string `koanf:"admin_allowed_cidrs"` WAFEnabled bool `koanf:"waf_enabled"` // Enable Coraza WAF (default: false) WAFParanoiaLevel int `koanf:"waf_paranoia_level"` // OWASP CRS paranoia level 1-4 (default: 2) + // WAFCRSMode selects the OWASP Core Rule Set layer mode when the WAF is + // enabled: "off" (inline rules only), "detect" (CRS evaluated, matches + // logged, never blocks) or "block" (CRS anomaly-scoring blocking). + // Defaults to "detect": chat traffic routinely contains SQL-ish/HTML-ish + // text the CRS false-positives on, so blocking needs tuning against real + // traffic first. Unknown values fall back to "detect". + WAFCRSMode string `koanf:"waf_crs_mode"` } // DatabaseConfig holds database settings. @@ -209,6 +226,7 @@ func defaults() Config { "192.168.0.0/16", // private class C "fc00::/7", // IPv6 unique local }, + WAFCRSMode: "detect", }, Database: DatabaseConfig{ Type: "sqlite", @@ -274,6 +292,10 @@ server: # - "10.0.0.0/8" # - "172.16.0.0/12" # - "192.168.0.0/16" + # waf_enabled: false # Coraza WAF (inline rules + OWASP Core Rule Set) + # waf_paranoia_level: 2 # OWASP CRS paranoia level 1-4 + # waf_crs_mode: "detect" # off | detect | block — CRS layer mode; "detect" logs + # # CRS matches without blocking (safe default for chat traffic) database: type: "sqlite" # "sqlite" is the only supported backend @@ -294,7 +316,12 @@ voice: # livekit_api_key: "" # LiveKit API key (REQUIRED for voice — generate a unique key) # livekit_api_secret: "" # LiveKit API secret (REQUIRED, min 32 chars — generate a unique secret) livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL - # livekit_binary: "" # path to livekit-server binary; empty = don't auto-start + auto_download_livekit: true # download and run livekit-server automatically when + # no livekit_binary is set (verified against the + # official LiveKit release checksums; stored in data/livekit/) + # livekit_version: "" # override the pinned livekit-server version (e.g. "1.13.5") + # livekit_binary: "" # path to an existing livekit-server binary; set this to + # # skip auto-download and run your own build # node_ip: "" # public IP for WebRTC media (required for remote users behind NAT) # advertise_internal_ip: false # also advertise LAN IPs so local-network clients can connect # quality: "medium" # low | medium | high @@ -339,9 +366,9 @@ voice: # gif: # api_key: "" -# Logging. "level" gates what is written to stdout; the admin panel's live log -# view always captures debug regardless. Override without editing this file via -# the OWNCORD_LOGGING_LEVEL environment variable. +# Logging. "level" gates what is logged, to stdout and the admin panel's live +# log view alike. Override without editing this file via the +# OWNCORD_LOGGING_LEVEL environment variable. # logging: # level: "info" # debug | info | warn | error ` @@ -358,23 +385,26 @@ func Load(cfgPath string) (*Config, error) { return nil, fmt.Errorf("loading defaults: %w", err) } - // Layer 2: YAML file (create default if missing). + // Layer 2: YAML file (create default if missing). The freshly written + // default file is loaded like any other so the first boot runs with + // exactly the configuration the file documents (the generated template + // enables options — e.g. voice.auto_download_livekit — that the + // compiled-in defaults deliberately leave off for pre-existing configs). if _, err := os.Stat(cfgPath); os.IsNotExist(err) { if writeErr := os.WriteFile(cfgPath, []byte(defaultYAML), 0o600); writeErr != nil { return nil, fmt.Errorf("writing default config: %w", writeErr) } - } else { - // Read the file and try to parse it ourselves to detect invalid YAML. - raw, readErr := os.ReadFile(cfgPath) - if readErr != nil { - return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr) - } - if parseErr := validateYAML(raw); parseErr != nil { - return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr) - } - if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil { - return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err) - } + } + // Read the file and try to parse it ourselves to detect invalid YAML. + raw, readErr := os.ReadFile(cfgPath) //nolint:gosec // G304: path from trusted wiring + if readErr != nil { + return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr) + } + if parseErr := validateYAML(raw); parseErr != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr) + } + if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err) } // Layer 3: environment variable overrides. diff --git a/Server/config/logvalue.go b/Server/config/logvalue.go index cbee7bfd..f30b7f80 100644 --- a/Server/config/logvalue.go +++ b/Server/config/logvalue.go @@ -26,6 +26,8 @@ func (v VoiceConfig) LogValue() slog.Value { slog.String("livekit_api_secret", redactSecret(v.LiveKitAPISecret)), slog.String("livekit_url", v.LiveKitURL), slog.String("livekit_binary", v.LiveKitBinaryPath), + slog.Bool("auto_download_livekit", v.AutoDownloadLiveKit), + slog.String("livekit_version", v.LiveKitVersion), slog.String("node_ip", v.NodeIP), slog.Bool("advertise_internal_ip", v.AdvertiseInternalIP), slog.String("quality", v.Quality), diff --git a/Server/config/save.go b/Server/config/save.go new file mode 100644 index 00000000..72744281 --- /dev/null +++ b/Server/config/save.go @@ -0,0 +1,264 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" + goyaml "go.yaml.in/yaml/v3" +) + +// DefaultPath is the CWD-relative config file path the server loads at +// startup. Shared by main.go, token_cli.go and the setup wizard so they can +// never disagree about which file is authoritative. +const DefaultPath = "config.yaml" + +// Patch lists the config.yaml keys the first-run setup wizard may change. +// A nil field leaves the file's current value untouched. +type Patch struct { + ServerPort *int + ServerName *string + TLSMode *string // self_signed | acme | manual | off + TLSDomain *string + UploadMaxSizeMB *int + VoiceQuality *string // low | medium | high + // VoiceAutoDownload toggles voice.auto_download_livekit (download and run + // livekit-server automatically when no livekit_binary is set). + VoiceAutoDownload *bool + + // VoiceAPIKey/VoiceAPISecret are written ONLY when the file's + // corresponding value is absent or empty. This persists the + // runtime-generated LiveKit credentials (see applyVoiceDefaults) so voice + // tokens survive restarts, without ever clobbering operator-set values. + VoiceAPIKey *string + VoiceAPISecret *string +} + +// saveMu serialises Save calls so concurrent writers cannot interleave the +// read-modify-write cycle. +var saveMu sync.Mutex + +// Save patches the config file at path with the non-nil fields of p, +// preserving comments, key order, hand edits and keys it does not model. +// The write is atomic (temp file + rename) and the result is verified to +// parse and unmarshal before it replaces the original — Save never leaves +// behind a file the server would refuse to boot from. +func Save(path string, p Patch) error { + saveMu.Lock() + defer saveMu.Unlock() + + raw, err := os.ReadFile(path) //nolint:gosec // G304: path comes from trusted wiring, not request input + if errors.Is(err, os.ErrNotExist) { + // File deleted since startup — start from the shipped template so the + // documentation comments still end up in the patched file. + raw = []byte(defaultYAML) + } else if err != nil { + return fmt.Errorf("reading config file %s: %w", path, err) + } + + var doc goyaml.Node + if err := goyaml.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("parsing config file %s: %w", path, err) + } + root, err := mappingRoot(&doc) + if err != nil { + return fmt.Errorf("config file %s: %w", path, err) + } + + applyPatch(root, p) + + var buf bytes.Buffer + enc := goyaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&doc); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + if err := enc.Close(); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + + // Sanity gate: the buffer must survive the same parse+unmarshal path Load + // uses. A bug here must fail the request, never brick the server's boot. + if err := verifyLoadable(buf.Bytes()); err != nil { + return fmt.Errorf("refusing to write config that would not load: %w", err) + } + + return atomicWrite(path, buf.Bytes()) +} + +// applyPatch upserts every non-nil Patch field into the document root. +func applyPatch(root *goyaml.Node, p Patch) { + if p.ServerPort != nil { + setScalar(section(root, "server"), "port", strconv.Itoa(*p.ServerPort), "!!int") + } + if p.ServerName != nil { + setScalar(section(root, "server"), "name", *p.ServerName, "!!str") + } + if p.TLSMode != nil { + setScalar(section(root, "tls"), "mode", *p.TLSMode, "!!str") + } + if p.TLSDomain != nil { + setScalar(section(root, "tls"), "domain", *p.TLSDomain, "!!str") + } + if p.UploadMaxSizeMB != nil { + setScalar(section(root, "upload"), "max_size_mb", strconv.Itoa(*p.UploadMaxSizeMB), "!!int") + } + if p.VoiceQuality != nil { + setScalar(section(root, "voice"), "quality", *p.VoiceQuality, "!!str") + } + if p.VoiceAutoDownload != nil { + setScalar(section(root, "voice"), "auto_download_livekit", strconv.FormatBool(*p.VoiceAutoDownload), "!!bool") + } + if p.VoiceAPIKey != nil { + if cur := findValue(section(root, "voice"), "livekit_api_key"); cur == nil || cur.Value == "" { + setScalar(section(root, "voice"), "livekit_api_key", *p.VoiceAPIKey, "!!str") + } + } + if p.VoiceAPISecret != nil { + if cur := findValue(section(root, "voice"), "livekit_api_secret"); cur == nil || cur.Value == "" { + setScalar(section(root, "voice"), "livekit_api_secret", *p.VoiceAPISecret, "!!str") + } + } +} + +// mappingRoot returns the top-level mapping of the parsed document, creating +// an empty document+mapping when the file was empty or comments-only. +func mappingRoot(doc *goyaml.Node) (*goyaml.Node, error) { + if doc.Kind == 0 { + doc.Kind = goyaml.DocumentNode + } + if len(doc.Content) == 0 { + m := &goyaml.Node{Kind: goyaml.MappingNode, Tag: "!!map"} + doc.Content = []*goyaml.Node{m} + return m, nil + } + root := doc.Content[0] + // A document holding only a null scalar (e.g. a file with nothing but + // comments) can be promoted to a mapping in place, keeping its comments. + if root.Kind == goyaml.ScalarNode && root.Tag == "!!null" { + root.Kind = goyaml.MappingNode + root.Tag = "!!map" + root.Value = "" + return root, nil + } + if root.Kind != goyaml.MappingNode { + return nil, errors.New("root is not a YAML mapping") + } + return root, nil +} + +// section returns the mapping value node for a top-level section key, +// creating and appending the section when absent. A present-but-null section +// (e.g. bare "voice:" with all children commented out) is promoted to a +// mapping in place so its comments survive. +func section(root *goyaml.Node, name string) *goyaml.Node { + if v := findValue(root, name); v != nil { + if v.Kind != goyaml.MappingNode { + v.Kind = goyaml.MappingNode + v.Tag = "!!map" + v.Value = "" + v.Style = 0 + v.Content = nil + } + return v + } + k := &goyaml.Node{Kind: goyaml.ScalarNode, Tag: "!!str", Value: name} + v := &goyaml.Node{Kind: goyaml.MappingNode, Tag: "!!map"} + root.Content = append(root.Content, k, v) + return v +} + +// findValue returns the value node for key within a mapping node, or nil. +func findValue(m *goyaml.Node, key string) *goyaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// setScalar upserts key: value inside the mapping node m. Values are set as +// node content, never spliced into text, so a value containing YAML syntax +// is encoded as one (quoted) scalar — injection is structurally impossible. +func setScalar(m *goyaml.Node, key, value, tag string) { + if v := findValue(m, key); v != nil { + v.Kind = goyaml.ScalarNode + v.Tag = tag + v.Value = value + v.Style = 0 // let the encoder pick minimal correct quoting + v.Content = nil + return + } + m.Content = append(m.Content, + &goyaml.Node{Kind: goyaml.ScalarNode, Tag: "!!str", Value: key}, + &goyaml.Node{Kind: goyaml.ScalarNode, Tag: tag, Value: value}, + ) +} + +// bytesProvider adapts a raw byte slice to koanf's Provider interface so the +// verification pass can reuse the exact YAML parser Load uses, without a +// temp file or an extra dependency. +type bytesProvider []byte + +func (b bytesProvider) ReadBytes() ([]byte, error) { return b, nil } + +func (b bytesProvider) Read() (map[string]any, error) { + return nil, errors.New("bytesProvider requires a parser") +} + +// verifyLoadable checks that raw would survive Load's parse+unmarshal path. +func verifyLoadable(raw []byte) error { + if err := validateYAML(raw); err != nil { + return err + } + k := koanf.New(".") + if err := k.Load(structs.Provider(defaults(), "koanf"), nil); err != nil { + return err + } + if err := k.Load(bytesProvider(raw), yaml.Parser()); err != nil { + return err + } + var cfg Config + return k.Unmarshal("", &cfg) +} + +// atomicWrite replaces path with data via temp file + rename so a crash +// mid-write can never leave a truncated config. os.Rename replaces the +// destination on both Unix and Windows. +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".config-*.yaml.tmp") + if err != nil { + return fmt.Errorf("creating temp config: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) //nolint:errcheck // no-op after successful rename + + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path + return fmt.Errorf("setting temp config permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path + return fmt.Errorf("writing temp config: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path + return fmt.Errorf("syncing temp config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp config: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replacing config file: %w", err) + } + return nil +} diff --git a/Server/config/save_test.go b/Server/config/save_test.go new file mode 100644 index 00000000..802eb733 --- /dev/null +++ b/Server/config/save_test.go @@ -0,0 +1,279 @@ +package config_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/owncord/server/config" +) + +// loadNoEnv loads cfgPath ensuring no OWNCORD_ env overrides leak in from the +// test environment. +func loadNoEnv(t *testing.T, cfgPath string) *config.Config { + t.Helper() + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + return cfg +} + +func TestSavePatchesGeneratedDefaultFile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + // Generate the default file the way first startup does. + loadNoEnv(t, cfgPath) + + err := config.Save(cfgPath, config.Patch{ + ServerPort: new(9000), + ServerName: new("My Cool Server"), + TLSMode: new("off"), + UploadMaxSizeMB: new(250), + VoiceQuality: new("high"), + }) + if err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading patched file: %v", err) + } + text := string(raw) + + // Documentation comments must survive the round-trip. + for _, want := range []string{ + "# OwnCord Server Configuration", + "self_signed, acme, manual, off", + "# telemetry:", + } { + if !strings.Contains(text, want) { + t.Errorf("patched file lost comment %q", want) + } + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Server.Port != 9000 { + t.Errorf("Server.Port = %d, want 9000", cfg.Server.Port) + } + if cfg.Server.Name != "My Cool Server" { + t.Errorf("Server.Name = %q, want 'My Cool Server'", cfg.Server.Name) + } + if cfg.TLS.Mode != "off" { + t.Errorf("TLS.Mode = %q, want 'off'", cfg.TLS.Mode) + } + if cfg.Upload.MaxSizeMB != 250 { + t.Errorf("Upload.MaxSizeMB = %d, want 250", cfg.Upload.MaxSizeMB) + } + if cfg.Voice.Quality != "high" { + t.Errorf("Voice.Quality = %q, want 'high'", cfg.Voice.Quality) + } + // Untouched values keep their file defaults. + if cfg.Database.Path != "data/chatserver.db" { + t.Errorf("Database.Path = %q, want default", cfg.Database.Path) + } +} + +func TestSavePreservesHandEdits(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + handEdited := `# my important operator note +server: + port: 8443 + name: "Old Name" + trusted_proxies: ["10.0.0.2/32"] + +gif: + api_key: "klipy-secret" + +custom_section: + custom_key: 42 +` + if err := os.WriteFile(cfgPath, []byte(handEdited), 0o600); err != nil { + t.Fatalf("writing hand-edited file: %v", err) + } + + if err := config.Save(cfgPath, config.Patch{ServerName: new("New Name")}); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading patched file: %v", err) + } + text := string(raw) + for _, want := range []string{ + "# my important operator note", + "10.0.0.2/32", + "klipy-secret", + "custom_key: 42", + } { + if !strings.Contains(text, want) { + t.Errorf("patched file lost hand-edited content %q", want) + } + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Server.Name != "New Name" { + t.Errorf("Server.Name = %q, want 'New Name'", cfg.Server.Name) + } + if cfg.Server.Port != 8443 { + t.Errorf("Server.Port = %d, want 8443 (untouched)", cfg.Server.Port) + } + if cfg.GIF.APIKey != "klipy-secret" { + t.Errorf("GIF.APIKey = %q, want preserved", cfg.GIF.APIKey) + } +} + +func TestSaveCreatesMissingFileFromTemplate(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + if err := config.Save(cfgPath, config.Patch{ServerPort: new(9999)}); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + info, err := os.Stat(cfgPath) + if err != nil { + t.Fatalf("stat patched file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("file mode = %o, want 0600", perm) + } + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "# OwnCord Server Configuration") { + t.Error("file created from template lost the documentation header") + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Server.Port != 9999 { + t.Errorf("Server.Port = %d, want 9999", cfg.Server.Port) + } +} + +func TestSaveVoiceCredentialsOnlyWhenEmpty(t *testing.T) { + t.Run("written when absent", func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + loadNoEnv(t, cfgPath) // default file: livekit creds commented out + + err := config.Save(cfgPath, config.Patch{ + VoiceAPIKey: new("key-abc123"), + VoiceAPISecret: new("0123456789abcdef0123456789abcdef"), + }) + if err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Voice.LiveKitAPIKey != "key-abc123" { + t.Errorf("LiveKitAPIKey = %q, want persisted key", cfg.Voice.LiveKitAPIKey) + } + if cfg.Voice.LiveKitAPISecret != "0123456789abcdef0123456789abcdef" { + t.Errorf("LiveKitAPISecret = %q, want persisted secret", cfg.Voice.LiveKitAPISecret) + } + }) + + t.Run("never overwritten when set", func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + existing := `voice: + livekit_api_key: "operator-key" + livekit_api_secret: "operator-secret-thats-32-chars-x" +` + if err := os.WriteFile(cfgPath, []byte(existing), 0o600); err != nil { + t.Fatalf("writing file: %v", err) + } + + err := config.Save(cfgPath, config.Patch{ + VoiceAPIKey: new("key-should-not-win"), + VoiceAPISecret: new("secret-should-not-win-9876543210"), + }) + if err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Voice.LiveKitAPIKey != "operator-key" { + t.Errorf("LiveKitAPIKey = %q, operator value was clobbered", cfg.Voice.LiveKitAPIKey) + } + if cfg.Voice.LiveKitAPISecret != "operator-secret-thats-32-chars-x" { + t.Errorf("LiveKitAPISecret = %q, operator value was clobbered", cfg.Voice.LiveKitAPISecret) + } + }) +} + +func TestSaveVoiceAutoDownloadBool(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + loadNoEnv(t, cfgPath) // generated default file has auto_download_livekit: true + + if err := config.Save(cfgPath, config.Patch{VoiceAutoDownload: new(false)}); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + cfg := loadNoEnv(t, cfgPath) + if cfg.Voice.AutoDownloadLiveKit { + t.Error("Voice.AutoDownloadLiveKit = true, want false after patch") + } + + if err := config.Save(cfgPath, config.Patch{VoiceAutoDownload: new(true)}); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + cfg = loadNoEnv(t, cfgPath) + if !cfg.Voice.AutoDownloadLiveKit { + t.Error("Voice.AutoDownloadLiveKit = false, want true after re-patch") + } +} + +func TestSaveYAMLInjectionIsInert(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + loadNoEnv(t, cfgPath) + + hostile := "x\nserver:\n port: 1 # pwned" + if err := config.Save(cfgPath, config.Patch{ServerName: new(hostile)}); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + cfg := loadNoEnv(t, cfgPath) + if cfg.Server.Name != hostile { + t.Errorf("Server.Name = %q, want the hostile string as one inert scalar", cfg.Server.Name) + } + if cfg.Server.Port != 8443 { + t.Errorf("Server.Port = %d, want 8443 — injection changed a sibling key", cfg.Server.Port) + } +} + +func TestSaveLeavesUnparseableFileIntact(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + for name, content := range map[string]string{ + "invalid yaml": "\tserver: [", + "root not mapping": "just a scalar\n", + } { + t.Run(name, func(t *testing.T) { + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { + t.Fatalf("writing file: %v", err) + } + if err := config.Save(cfgPath, config.Patch{ServerPort: new(9000)}); err == nil { + t.Fatal("Save() succeeded on a file it cannot safely patch") + } + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading file: %v", err) + } + if string(raw) != content { + t.Error("failed Save() modified the original file") + } + }) + } +} diff --git a/Server/db/account.go b/Server/db/account.go index 20eb2899..acdfe91f 100644 --- a/Server/db/account.go +++ b/Server/db/account.go @@ -27,7 +27,7 @@ import ( // After this the account is completely unusable and all personal data is // removed while preserving referential integrity for historical records. func (d *DB) DeleteAccount(ctx context.Context, userID int64) error { - tx, err := d.sqlDB.BeginTx(ctx, nil) + tx, err := d.writer.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("DeleteAccount begin tx: %w", err) } diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index 5d460e20..35b2cb24 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -47,11 +47,12 @@ func (d *DB) GetServerStats(ctx context.Context) (*ServerStats, error) { // page_count * page_size gives the database size in bytes. PRAGMAs are not // expressible as sqlc queries, so they stay on the raw connection. // For :memory: databases this still works (returns the in-memory size). + // Both values are DB-wide, so reading them on the reader pool is fine. var pageCount, pageSize int64 - if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err != nil { + if err := d.reader.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err != nil { return nil, fmt.Errorf("GetServerStats page_count: %w", err) } - if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil { + if err := d.reader.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil { return nil, fmt.Errorf("GetServerStats page_size: %w", err) } stats.DBSizeBytes = pageCount * pageSize @@ -129,7 +130,7 @@ func (d *DB) GetUserSessions(ctx context.Context, userID int64) ([]Session, erro // AdminCreateChannel creates a channel with full field control including position. // No sqlc query covers this exact INSERT shape, so it stays on raw SQL. func (d *DB) AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) { - res, err := d.sqlDB.ExecContext(ctx, + res, err := d.writer.ExecContext(ctx, `INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?)`, name, chanType, strToNullPtr(category), strToNullPtr(topic), position, @@ -179,6 +180,64 @@ func (d *DB) LogAudit(ctx context.Context, actorID int64, action, targetType str return nil } +// PersistAudits inserts a batch of audit entries in a single transaction with +// one prepared insert, so the audit writer's flush pays for one fsync instead +// of one per entry. Only the LogAudit-shaped fields are written — ID, ActorName +// and CreatedAt on the input rows are ignored (the id autoincrements, the +// created_at column defaults, and actor_name is a join product). +// +// Best-effort semantics mirror PersistEvents: if the batched transaction fails, +// it falls back to per-row inserts so the good rows still land. Returns the +// number of rows persisted and, when any row was lost, the first per-row error. +func (d *DB) PersistAudits(ctx context.Context, entries []AuditEntry) (int, error) { + if len(entries) == 0 { + return 0, nil + } + if err := d.persistAuditsTx(ctx, entries); err == nil { + return len(entries), nil + } + // Fallback: insert rows individually so one bad row doesn't drop the batch. + persisted := 0 + var firstErr error + for _, e := range entries { + if err := d.LogAudit(ctx, e.ActorID, e.Action, e.TargetType, e.TargetID, e.Detail); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + persisted++ + } + return persisted, firstErr +} + +// persistAuditsTx inserts all entries inside one transaction; any failure +// rolls the whole batch back. +func (d *DB) persistAuditsTx(ctx context.Context, entries []AuditEntry) error { + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("PersistAudits begin tx: %w", err) + } + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) VALUES (?, ?, ?, ?, ?)`, + ) + if err != nil { + _ = tx.Rollback() + return fmt.Errorf("PersistAudits prepare: %w", err) + } + defer func() { _ = stmt.Close() }() + for _, e := range entries { + if _, err := stmt.ExecContext(ctx, e.ActorID, e.Action, e.TargetType, e.TargetID, e.Detail); err != nil { + _ = tx.Rollback() + return fmt.Errorf("PersistAudits insert action %q: %w", e.Action, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("PersistAudits commit: %w", err) + } + return nil +} + // GetAuditLog returns audit log entries ordered newest-first with pagination. func (d *DB) GetAuditLog(ctx context.Context, limit, offset int) ([]AuditEntry, error) { rows, err := d.q.GetAuditLog(ctx, dbgen.GetAuditLogParams{ @@ -311,7 +370,7 @@ func (d *DB) BackupToSafe(ctx context.Context, path, safeRoot string) error { return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", "--") } - _, err = d.sqlDB.ExecContext(ctx, fmt.Sprintf("VACUUM INTO '%s'", absClean)) + _, err = d.writer.ExecContext(ctx, fmt.Sprintf("VACUUM INTO '%s'", absClean)) if err != nil { return fmt.Errorf("BackupToSafe: %w", err) } diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index 14a9630f..c0580203 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -128,7 +128,7 @@ func (d *DB) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID AND (uploader_id = ? OR uploader_id IS NULL)`, strings.Join(placeholders, ","), ) - res, err := d.sqlDB.ExecContext(ctx, query, args...) + res, err := d.writer.ExecContext(ctx, query, args...) if err != nil { return 0, fmt.Errorf("LinkAttachmentsToMessage: %w", err) } @@ -153,7 +153,7 @@ func (d *DB) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (ma FROM attachments WHERE message_id IN (%s)`, strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.QueryContext(ctx, query, args...) + rows, err := d.reader.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err) } diff --git a/Server/db/audit.go b/Server/db/audit.go index e12f348d..829d102f 100644 --- a/Server/db/audit.go +++ b/Server/db/audit.go @@ -13,6 +13,18 @@ type Auditor interface { LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error } +// AsyncAuditor is the optional asynchronous fast path for WriteAudit. An +// Auditor that also implements it — in practice *DB, once main.go installs +// an AuditWriter via SetAuditWriter — can take the entry off the request +// path. EnqueueAudit reports true when it took responsibility for the entry +// (the background writer may still drop it under load, but never silently — +// see AuditWriter.Enqueue), and false when no writer is installed, in which +// case WriteAudit performs the synchronous best-effort write below. The +// token CLI and tests never install a writer, so they stay synchronous. +type AsyncAuditor interface { + EnqueueAudit(actorID int64, action, targetType string, targetID int64, detail string) bool +} + // WriteAudit records an audit entry best-effort. // // Per the D8 policy decision (docs/plans/audit-2026-07-19-decisions.md), audit @@ -23,6 +35,9 @@ type Auditor interface { // it can carry request-specific or sensitive text and the structured fields // already identify what was attempted. func WriteAudit(ctx context.Context, a Auditor, actorID int64, action, targetType string, targetID int64, detail string) { + if aa, ok := a.(AsyncAuditor); ok && aa.EnqueueAudit(actorID, action, targetType, targetID, detail) { + return + } if err := a.LogAudit(ctx, actorID, action, targetType, targetID, detail); err != nil { slog.Error("audit log write failed", "action", action, diff --git a/Server/db/audit_writer.go b/Server/db/audit_writer.go new file mode 100644 index 00000000..3612aa22 --- /dev/null +++ b/Server/db/audit_writer.go @@ -0,0 +1,260 @@ +// Async audit writer. +// +// AuditWriter is an asynchronous batched writer that drains audit entries +// from an in-memory channel into the audit_log table. It is modeled on +// ws.EventPersister: it must never block the request path, so when the queue +// is full the entry is dropped and a counter is incremented. Per the D8 +// policy decision (docs/plans/audit-2026-07-19-decisions.md) a drop is never +// silent — it is logged with the actor/action/target context (never the +// detail string, which can carry sensitive text), and flush losses are +// logged the same way. + +package db + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" + "time" +) + +// AuditStore is the minimal batch-write surface AuditWriter needs. *DB +// satisfies it directly; tests substitute a fake. +type AuditStore interface { + PersistAudits(ctx context.Context, entries []AuditEntry) (int, error) +} + +// pendingAudit is a single audit entry waiting to be flushed to the store. +// Fields mirror LogAudit's parameters. +type pendingAudit struct { + actorID int64 + action string + targetType string + targetID int64 + detail string +} + +// AuditWriter batches audit entries and writes them to an AuditStore. +type AuditWriter struct { + store AuditStore + queue chan pendingAudit + batchSize int + flushEvery time.Duration + + startOnce sync.Once + started atomic.Bool + 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 + flushes atomic.Uint64 + errors atomic.Uint64 +} + +// NewAuditWriter returns a writer wired to s. s MUST be non-nil — run() +// dereferences w.store on every flush, so a nil store would panic on the +// first tick. We fail fast here so the misconfiguration surfaces at +// construction time (main.go, tests) instead of minutes later in the +// background goroutine. +// +// queueSize sets the channel buffer; once full, Enqueue drops (loudly, per +// D8) without blocking. batchSize and flushEvery control the flush triggers. +func NewAuditWriter(s AuditStore, queueSize, batchSize int, flushEvery time.Duration) *AuditWriter { + if s == nil { + panic("db: NewAuditWriter requires a non-nil AuditStore") + } + if queueSize <= 0 { + queueSize = 1024 + } + if batchSize <= 0 { + batchSize = 50 + } + if flushEvery <= 0 { + flushEvery = 100 * time.Millisecond + } + return &AuditWriter{ + store: s, + queue: make(chan pendingAudit, queueSize), + batchSize: batchSize, + flushEvery: flushEvery, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start launches the background flusher goroutine. Idempotent — calling +// Start more than once is a no-op so test setups that share a writer across +// cases don't spawn duplicate runners. +func (w *AuditWriter) Start(ctx context.Context) { + w.startOnce.Do(func() { + w.started.Store(true) + go w.run(ctx) + }) +} + +// Enqueue queues an audit entry for persistence. Non-blocking; on a full +// queue the entry is dropped, but never silently (D8): the drop is counted +// and logged with the same identifying fields WriteAudit uses for a +// synchronous failure. The detail string is intentionally not logged. +func (w *AuditWriter) Enqueue(actorID int64, action, targetType string, targetID int64, detail string) { + if w == nil { + return + } + select { + case w.queue <- pendingAudit{actorID: actorID, action: action, targetType: targetType, targetID: targetID, detail: detail}: + default: + w.dropped.Add(1) + slog.Error("audit log dropped: queue full", + "action", action, + "actor_id", actorID, + "target_type", targetType, + "target_id", targetID, + ) + } +} + +// Stop signals the writer to drain remaining entries 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 audits 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 (w *AuditWriter) Stop(ctx context.Context) { + if w == nil { + return + } + w.stopOnce.Do(func() { + // Published before close(w.stop): run() reads stopCtxDone only after + // its receive on w.stop observes the close, and the close/receive + // pair makes this write visible without a data race. + w.stopCtxDone = ctx.Done() + close(w.stop) + }) + if !w.started.Load() { + // run() was never launched, so done will never be closed. + return + } + // Always wait for the goroutine to exit — never race it against ctx. + <-w.done +} + +// Stats returns lifetime counters. +func (w *AuditWriter) Stats() (persisted, dropped, flushes, errs uint64) { + return w.persisted.Load(), w.dropped.Load(), w.flushes.Load(), w.errors.Load() +} + +func (w *AuditWriter) run(ctx context.Context) { + defer close(w.done) + tick := time.NewTicker(w.flushEvery) + defer tick.Stop() + + batch := make([]pendingAudit, 0, w.batchSize) + // Scratch slice reused across flushes for the store's batch shape. + rows := make([]AuditEntry, 0, w.batchSize) + flush := func() { + if len(batch) == 0 { + return + } + w.flushes.Add(1) + rows = rows[:0] + for _, a := range batch { + rows = append(rows, AuditEntry{ + ActorID: a.actorID, + Action: a.action, + TargetType: a.targetType, + TargetID: a.targetID, + Detail: a.detail, + }) + } + // One transaction per flush instead of one autocommit write per entry. + // PersistAudits keeps the best-effort contract: on tx failure it + // retries per-row so a single bad entry doesn't drop the batch. + persisted, err := w.store.PersistAudits(ctx, rows) + if persisted > 0 { + w.persisted.Add(uint64(persisted)) + } + if failed := len(batch) - persisted; failed > 0 { + w.errors.Add(uint64(failed)) //nolint:gosec // failed is non-negative + // D8: a lost audit write is never silent. PersistAudits already + // wraps the first row error with its action context. + slog.Error("audit writer: flush lost audit entries", + "failed", failed, "batch", len(batch), "error", err) + } + batch = batch[:0] + } + + for { + select { + case <-w.stop: + // Drain anything still in the channel before exiting. The drain is + // bounded by the Stop context (w.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 w.done), so Stop's caller + // never closes the store under a live flusher. + for { + select { + case a := <-w.queue: + batch = append(batch, a) + if len(batch) >= w.batchSize { + flush() + } + case <-w.stopCtxDone: + flush() + return + default: + flush() + return + } + } + case <-ctx.Done(): + flush() + return + case a := <-w.queue: + batch = append(batch, a) + if len(batch) >= w.batchSize { + flush() + } + case <-tick.C: + flush() + } + } +} + +// ── *DB integration ───────────────────────────────────────────────────────── + +// SetAuditWriter installs w as this DB's asynchronous audit path. Once +// installed, WriteAudit calls whose Auditor is backed by this *DB enqueue on +// the writer instead of inserting synchronously. Paths that never install a +// writer — the token CLI, tests — keep the synchronous behavior. Safe for +// concurrent use; storing nil uninstalls. +func (d *DB) SetAuditWriter(w *AuditWriter) { + d.auditWriter.Store(w) +} + +// EnqueueAudit implements AsyncAuditor. It reports false when no writer is +// installed so WriteAudit falls back to the synchronous path. +func (d *DB) EnqueueAudit(actorID int64, action, targetType string, targetID int64, detail string) bool { + w := d.auditWriter.Load() + if w == nil { + return false + } + w.Enqueue(actorID, action, targetType, targetID, detail) + return true +} diff --git a/Server/db/audit_writer_test.go b/Server/db/audit_writer_test.go new file mode 100644 index 00000000..f6d6e78c --- /dev/null +++ b/Server/db/audit_writer_test.go @@ -0,0 +1,452 @@ +package db_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/owncord/server/db" +) + +// fakeAuditStore records the batches handed to PersistAudits so tests can +// assert on batching behavior without a real database. +type fakeAuditStore struct { + mu sync.Mutex + batches [][]db.AuditEntry + entries []db.AuditEntry + err error // when non-nil, PersistAudits persists nothing +} + +func (f *fakeAuditStore) PersistAudits(_ context.Context, entries []db.AuditEntry) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return 0, f.err + } + cp := append([]db.AuditEntry(nil), entries...) + f.batches = append(f.batches, cp) + f.entries = append(f.entries, cp...) + return len(entries), nil +} + +func (f *fakeAuditStore) snapshot() (batches int, entries []db.AuditEntry) { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.batches), append([]db.AuditEntry(nil), f.entries...) +} + +// waitForPersisted polls the writer's Stats until the persisted counter +// reaches want or the deadline passes. +func waitForPersisted(t *testing.T, w *db.AuditWriter, want uint64) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if persisted, _, _, _ := w.Stats(); persisted >= want { + return + } + time.Sleep(time.Millisecond) + } + persisted, dropped, flushes, errs := w.Stats() + t.Fatalf("timed out waiting for persisted=%d; stats: persisted=%d dropped=%d flushes=%d errors=%d", + want, persisted, dropped, flushes, errs) +} + +func TestAuditWriter_BatchFlush(t *testing.T) { + store := &fakeAuditStore{} + // flushEvery is huge so the only flush trigger is the batch filling up — + // this pins that a full batch goes to the store as one PersistAudits call. + w := db.NewAuditWriter(store, 16, 4, time.Hour) + defer w.Stop(context.Background()) + + // Enqueue before Start so the run loop sees all four immediately. + for i := int64(1); i <= 4; i++ { + w.Enqueue(i, fmt.Sprintf("action_%d", i), "user", i*10, fmt.Sprintf("detail_%d", i)) + } + w.Start(context.Background()) + waitForPersisted(t, w, 4) + + batches, entries := store.snapshot() + if batches != 1 { + t.Errorf("store received %d batches, want 1 (batch-size flush)", batches) + } + if len(entries) != 4 { + t.Fatalf("store received %d entries, want 4", len(entries)) + } + // Field mapping and order must survive the queue round-trip. + for i, e := range entries { + n := int64(i + 1) + if e.ActorID != n || e.Action != fmt.Sprintf("action_%d", n) || + e.TargetType != "user" || e.TargetID != n*10 || e.Detail != fmt.Sprintf("detail_%d", n) { + t.Errorf("entry %d = %+v, want actor=%d action=action_%d target=user/%d detail=detail_%d", + i, e, n, n, n*10, n) + } + } +} + +func TestAuditWriter_DropOnFullQueueLogsError(t *testing.T) { + store := &fakeAuditStore{} + // Queue of one, not yet started: the second enqueue must drop. + w := db.NewAuditWriter(store, 1, 50, time.Hour) + + out := captureLogs(t, func() { + w.Enqueue(1, "kept_action", "user", 1, "kept detail") + w.Enqueue(7, "dropped_action", "user", 42, "secret detail") + }) + + if _, dropped, _, _ := w.Stats(); dropped != 1 { + t.Errorf("dropped counter = %d, want 1", dropped) + } + // D8: the drop must not be silent and must identify what was lost. + for _, want := range []string{ + "audit log dropped", + "action=dropped_action", + "actor_id=7", + "target_type=user", + "target_id=42", + } { + if !strings.Contains(out, want) { + t.Errorf("drop log missing %q; got: %s", want, out) + } + } + // The detail string must not leak into logs. + if strings.Contains(out, "secret detail") { + t.Errorf("detail string leaked into drop log: %s", out) + } + + // The queued entry must still land once the writer runs. + w.Start(context.Background()) + w.Stop(context.Background()) + if persisted, _, _, _ := w.Stats(); persisted != 1 { + t.Errorf("persisted = %d, want 1 (the non-dropped entry)", persisted) + } +} + +func TestAuditWriter_DrainOnStop(t *testing.T) { + store := &fakeAuditStore{} + // Neither flush trigger can fire (batch 50, ticker 1h): everything must + // be flushed by Stop's drain. + w := db.NewAuditWriter(store, 64, 50, time.Hour) + w.Start(context.Background()) + for i := range int64(10) { + w.Enqueue(i, "drain_action", "user", i, "") + } + w.Stop(context.Background()) + + persisted, dropped, _, _ := w.Stats() + if persisted != 10 || dropped != 0 { + t.Errorf("persisted=%d dropped=%d, want 10/0", persisted, dropped) + } + if _, entries := store.snapshot(); len(entries) != 10 { + t.Errorf("store received %d entries after Stop, want 10", len(entries)) + } +} + +// slowAuditStore models a store whose flushes take a while (a slow/stalled +// disk). It tracks whether any flush ran after Close was called — mirroring +// main.go closing the database right after AuditWriter.Stop returns. +type slowAuditStore struct { + mu sync.Mutex + delay time.Duration + closed bool + persisted int + flushAfter int // entries flushed after Close (a correctness violation) + flushesAfter int +} + +func (s *slowAuditStore) PersistAudits(_ context.Context, entries []db.AuditEntry) (int, error) { + s.mu.Lock() + closedAtEntry := s.closed + delay := s.delay + s.mu.Unlock() + + if delay > 0 { + time.Sleep(delay) + } + + s.mu.Lock() + defer s.mu.Unlock() + if closedAtEntry || s.closed { + s.flushesAfter++ + s.flushAfter += len(entries) + } + s.persisted += len(entries) + return len(entries), nil +} + +func (s *slowAuditStore) close() { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true +} + +func (s *slowAuditStore) stats() (persisted, flushAfter, flushesAfter int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.persisted, s.flushAfter, s.flushesAfter +} + +// TestAuditWriter_StopWaitsForGoroutineExit 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. The fix must return only after the flush +// completes, with every entry persisted. +func TestAuditWriter_StopWaitsForGoroutineExit(t *testing.T) { + store := &slowAuditStore{delay: 200 * time.Millisecond} + // Neither the batch (50) nor the ticker (1h) can flush; only Stop's drain + // flushes, so the in-flight flush is deterministic. + w := db.NewAuditWriter(store, 64, 50, time.Hour) + w.Start(context.Background()) + for i := range int64(5) { + w.Enqueue(i, "slow_action", "user", i, "") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + start := time.Now() + w.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)", elapsed) + } + persisted, flushAfter, _ := store.stats() + if persisted != 5 { + t.Errorf("persisted=%d, want 5 (Stop must wait for the flush to finish)", persisted) + } + if flushAfter != 0 { + t.Errorf("flushAfter=%d, want 0 (nothing was closed yet)", flushAfter) + } + if p, _, _, _ := w.Stats(); p != 5 { + t.Errorf("writer persisted counter = %d, want 5", p) + } +} + +// 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 +// after the store is closed — so a slow-disk shutdown never writes into a +// closed pool (the D8 audit-loss race). +func TestAuditWriter_StopDrainsBeforeStoreClose(t *testing.T) { + store := &slowAuditStore{delay: 200 * time.Millisecond} + w := db.NewAuditWriter(store, 64, 50, time.Hour) + w.Start(context.Background()) + for i := range int64(5) { + w.Enqueue(i, "shutdown_action", "user", i, "") + } + + // Stop ctx expires long before the flush finishes, as in a >5s disk stall. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + w.Stop(ctx) + + // Mirror main.go: close the store immediately after Stop returns. + store.close() + + persisted, flushAfter, flushesAfter := store.stats() + if flushAfter != 0 || flushesAfter != 0 { + t.Errorf("%d entries across %d flushes ran after store close; want 0 "+ + "(Stop must fully drain before the DB is closed)", flushAfter, flushesAfter) + } + if persisted != 5 { + t.Errorf("persisted=%d, want 5 before store close", persisted) + } +} + +func TestAuditWriter_FlushFailureCountsAndLogs(t *testing.T) { + store := &fakeAuditStore{err: errors.New("disk on fire")} + w := db.NewAuditWriter(store, 16, 50, time.Hour) + + out := captureLogs(t, func() { + w.Start(context.Background()) + w.Enqueue(1, "lost_action", "user", 1, "") + w.Enqueue(2, "lost_action", "user", 2, "") + w.Stop(context.Background()) + }) + + if _, _, _, errs := w.Stats(); errs != 2 { + t.Errorf("errors counter = %d, want 2", errs) + } + for _, want := range []string{"flush lost audit entries", "disk on fire"} { + if !strings.Contains(out, want) { + t.Errorf("flush-failure log missing %q; got: %s", want, out) + } + } +} + +func TestAuditWriter_NilReceiverIsSafe(t *testing.T) { + var w *db.AuditWriter + w.Enqueue(1, "a", "user", 1, "") // must not panic + w.Stop(context.Background()) // must not panic +} + +func TestNewAuditWriter_PanicsOnNilStore(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("NewAuditWriter(nil, ...) did not panic") + } + }() + db.NewAuditWriter(nil, 0, 0, 0) +} + +func TestAuditWriter_ConcurrentEnqueue(t *testing.T) { + store := &fakeAuditStore{} + w := db.NewAuditWriter(store, 4096, 32, time.Millisecond) + w.Start(context.Background()) + + const goroutines, perGoroutine = 8, 250 + var wg sync.WaitGroup + for g := range goroutines { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := range perGoroutine { + w.Enqueue(int64(g), "concurrent_action", "user", int64(i), "") + } + }(g) + } + wg.Wait() + w.Stop(context.Background()) + + persisted, dropped, _, _ := w.Stats() + if persisted+dropped != goroutines*perGoroutine { + t.Errorf("persisted(%d)+dropped(%d) = %d, want %d", + persisted, dropped, persisted+dropped, goroutines*perGoroutine) + } + if _, entries := store.snapshot(); uint64(len(entries)) != persisted { + t.Errorf("store received %d entries, want %d (persisted counter)", len(entries), persisted) + } +} + +// ─── PersistAudits (batch insert + per-row fallback) ───────────────────────── + +func TestPersistAudits_SingleTransaction(t *testing.T) { + database := newAdminTestDB(t) + uid := seedUser(t, database, "batchactor") + + entries := []db.AuditEntry{ + {ActorID: uid, Action: "first", TargetType: "user", TargetID: 1, Detail: "d1"}, + {ActorID: uid, Action: "second", TargetType: "channel", TargetID: 2, Detail: "d2"}, + {ActorID: uid, Action: "third", TargetType: "server", TargetID: 0, Detail: ""}, + } + persisted, err := database.PersistAudits(context.Background(), entries) + if err != nil { + t.Fatalf("PersistAudits() error: %v", err) + } + if persisted != 3 { + t.Fatalf("PersistAudits() = %d, want 3", persisted) + } + + got, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(got) != 3 { + t.Fatalf("GetAuditLog() = %d entries, want 3", len(got)) + } + // Newest-first: the last inserted row comes back first. + if got[0].Action != "third" || got[2].Action != "first" { + t.Errorf("unexpected order: got[0]=%q got[2]=%q, want third/first", got[0].Action, got[2].Action) + } + if got[1].Detail != "d2" || got[1].TargetType != "channel" || got[1].TargetID != 2 { + t.Errorf("middle entry = %+v, want action=second target=channel/2 detail=d2", got[1]) + } +} + +func TestPersistAudits_PoisonRowFallsBackPerRow(t *testing.T) { + database := newAdminTestDB(t) + uid := seedUser(t, database, "poisonactor") + + // The admin test schema declares actor_id REFERENCES users(id) and Open + // enables foreign_keys, so a nonexistent actor poisons the transaction. + entries := []db.AuditEntry{ + {ActorID: uid, Action: "good_one", TargetType: "user", TargetID: 1}, + {ActorID: 999999, Action: "poison", TargetType: "user", TargetID: 2}, + {ActorID: uid, Action: "good_two", TargetType: "user", TargetID: 3}, + } + persisted, err := database.PersistAudits(context.Background(), entries) + if err == nil { + t.Error("PersistAudits() error = nil, want the poison row's error") + } + if persisted != 2 { + t.Fatalf("PersistAudits() = %d, want 2 (good rows land despite poison row)", persisted) + } + + got, dbErr := database.GetAuditLog(context.Background(), 10, 0) + if dbErr != nil { + t.Fatalf("GetAuditLog() error: %v", dbErr) + } + if len(got) != 2 { + t.Fatalf("GetAuditLog() = %d entries, want 2", len(got)) + } + if got[0].Action != "good_two" || got[1].Action != "good_one" { + t.Errorf("surviving actions = %q, %q; want good_two, good_one", got[0].Action, got[1].Action) + } +} + +func TestPersistAudits_EmptyBatch(t *testing.T) { + database := newAdminTestDB(t) + persisted, err := database.PersistAudits(context.Background(), nil) + if err != nil || persisted != 0 { + t.Errorf("PersistAudits(nil) = (%d, %v), want (0, nil)", persisted, err) + } +} + +// ─── WriteAudit routing (sync fallback vs installed writer) ────────────────── + +// TestWriteAudit_SynchronousWithoutWriter pins the token CLI contract: a bare +// *DB with no writer installed writes audit entries synchronously, so the +// entry is visible the moment WriteAudit returns. +func TestWriteAudit_SynchronousWithoutWriter(t *testing.T) { + database := newAdminTestDB(t) + uid := seedUser(t, database, "syncactor") + + db.WriteAudit(context.Background(), database, uid, "cli_action", "api_token", 5, "label") + + got, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(got) != 1 || got[0].Action != "cli_action" { + t.Fatalf("GetAuditLog() = %+v, want exactly the synchronously written cli_action", got) + } +} + +// TestWriteAudit_AsyncWithInstalledWriter verifies the seam: once main.go +// installs a writer on the *DB, WriteAudit enqueues instead of inserting — +// the row only lands when the writer flushes (here forced via Stop's drain). +func TestWriteAudit_AsyncWithInstalledWriter(t *testing.T) { + database := newAdminTestDB(t) + uid := seedUser(t, database, "asyncactor") + + // Neither flush trigger can fire before Stop, making "not yet written" + // deterministic rather than a timing accident. + w := db.NewAuditWriter(database, 16, 50, time.Hour) + w.Start(context.Background()) + database.SetAuditWriter(w) + + db.WriteAudit(context.Background(), database, uid, "async_action", "user", uid, "detail") + + got, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(got) != 0 { + t.Fatalf("entry visible before flush: %+v — WriteAudit did not take the async path", got) + } + + w.Stop(context.Background()) + got, err = database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog() after Stop error: %v", err) + } + if len(got) != 1 || got[0].Action != "async_action" || got[0].Detail != "detail" { + t.Fatalf("GetAuditLog() after Stop = %+v, want the drained async_action entry", got) + } +} diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index a44c0117..6363df3f 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" "github.com/owncord/server/db/dbgen" @@ -16,7 +17,7 @@ import ( // CreateUser inserts a new user record and returns the assigned ID. func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { - res, err := d.sqlDB.ExecContext(ctx, + res, err := d.writer.ExecContext(ctx, `INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`, username, passwordHash, roleID, ) @@ -30,7 +31,7 @@ func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, role // first owner in a single transaction. Returns ErrConflict if any user already // exists, closing the TOCTOU race in the setup endpoint (BUG-119). func (d *DB) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { - tx, err := d.sqlDB.BeginTx(ctx, nil) + tx, err := d.writer.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateOwnerIfEmpty begin: %w", err) } @@ -72,7 +73,7 @@ func (d *DB) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash stri // CreateUserWithInvite atomically consumes an invite and creates the user in // the same transaction so a failed registration does not burn the invite. func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) { - tx, err := d.sqlDB.BeginTx(ctx, nil) + tx, err := d.writer.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateUserWithInvite begin: %w", err) } @@ -298,6 +299,69 @@ func (d *DB) GetSessionWithBanStatus(ctx context.Context, tokenHash string) (*Se }, nil } +// GetSessionsWithBanStatusBatch returns session+ban rows for every token hash +// in tokenHashes, keyed by token hash. Hashes with no session row are simply +// absent from the map. Used by the ws revoked-session sweep so N connected +// clients cost one query per sweep instead of N. +func (d *DB) GetSessionsWithBanStatusBatch(ctx context.Context, tokenHashes []string) (map[string]*SessionWithBanStatus, error) { + result := make(map[string]*SessionWithBanStatus, len(tokenHashes)) + // Chunk the IN list to stay far below SQLite's bound-parameter limit. + const chunkSize = 500 + for start := 0; start < len(tokenHashes); start += chunkSize { + chunk := tokenHashes[start:min(start+chunkSize, len(tokenHashes))] + + placeholders := make([]string, len(chunk)) + args := make([]any, len(chunk)) + for i, hash := range chunk { + placeholders[i] = "?" + args[i] = hash + } + + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT s.id, s.user_id, s.token, s.device, s.ip_address, + s.created_at, s.last_used, s.expires_at, + u.banned, u.ban_reason, u.ban_expires + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := d.reader.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("GetSessionsWithBanStatusBatch: %w", err) + } + if err := scanSessionsWithBanStatus(rows, result); err != nil { + return nil, err + } + } + return result, nil +} + +// scanSessionsWithBanStatus scans batch rows into result and closes rows. +func scanSessionsWithBanStatus(rows *sql.Rows, result map[string]*SessionWithBanStatus) error { + defer rows.Close() //nolint:errcheck + for rows.Next() { + var s SessionWithBanStatus + var device, ip *string + var banned int + if err := rows.Scan( + &s.ID, &s.UserID, &s.TokenHash, &device, &ip, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + &banned, &s.BanReason, &s.BanExpires, + ); err != nil { + return fmt.Errorf("GetSessionsWithBanStatusBatch scan: %w", err) + } + s.Device = derefString(device) + s.IP = derefString(ip) + s.Banned = banned != 0 + result[s.TokenHash] = &s + } + if err := rows.Err(); err != nil { + return fmt.Errorf("GetSessionsWithBanStatusBatch rows: %w", err) + } + return nil +} + // DeleteSession removes the session with the given token hash. func (d *DB) DeleteSession(ctx context.Context, tokenHash string) error { if err := d.q.DeleteSessionByToken(ctx, tokenHash); err != nil { diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 423f555c..577f37a8 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -333,6 +333,63 @@ func TestGetSessionWithBanStatus_NotFound(t *testing.T) { } } +// The ws revoked-session sweep resolves every connected client in one +// IN (...) query; missing hashes must be absent (revoked ⇒ kick) and ban +// state must ride along per row. +func TestGetSessionsWithBanStatusBatch(t *testing.T) { + database := newTestDB(t) + okUID, _ := database.CreateUser(context.Background(), "batch-ok", "hash", 4) + banUID, _ := database.CreateUser(context.Background(), "batch-banned", "hash", 4) + _, _ = database.CreateSession(context.Background(), okUID, "batchTokenOK", "GoTest/1.0", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), banUID, "batchTokenBan", "GoTest/1.0", "127.0.0.1") + if err := database.BanUser(context.Background(), banUID, "rule violation", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + result, err := database.GetSessionsWithBanStatusBatch(context.Background(), + []string{"batchTokenOK", "batchTokenBan", "batchTokenMissing"}) + if err != nil { + t.Fatalf("GetSessionsWithBanStatusBatch: %v", err) + } + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2 (missing hash must be absent)", len(result)) + } + + ok := result["batchTokenOK"] + if ok == nil || ok.UserID != okUID { + t.Fatalf("batchTokenOK row = %+v, want session for user %d", ok, okUID) + } + if ok.Banned { + t.Error("batchTokenOK: Banned = true, want false") + } + if ok.ExpiresAt == "" { + t.Error("batchTokenOK: ExpiresAt empty — the sweep's expiry check needs it") + } + + banned := result["batchTokenBan"] + if banned == nil || !banned.Banned { + t.Fatalf("batchTokenBan row = %+v, want Banned = true", banned) + } + if banned.BanReason == nil || *banned.BanReason != "rule violation" { + t.Errorf("BanReason = %v, want 'rule violation'", banned.BanReason) + } + + if _, found := result["batchTokenMissing"]; found { + t.Error("batchTokenMissing must not be in the result map") + } +} + +func TestGetSessionsWithBanStatusBatch_Empty(t *testing.T) { + database := newTestDB(t) + result, err := database.GetSessionsWithBanStatusBatch(context.Background(), nil) + if err != nil { + t.Fatalf("GetSessionsWithBanStatusBatch(nil): %v", err) + } + if len(result) != 0 { + t.Errorf("len(result) = %d, want 0", len(result)) + } +} + func TestDeleteSession(t *testing.T) { database := newTestDB(t) uid, _ := database.CreateUser(context.Background(), "leo", "hash", 4) diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 828c50aa..efa22af6 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -210,7 +210,7 @@ type ChannelRoleOverride struct { // on the given channel (zero allow/deny when no override row exists), ordered // by role position descending. func (d *DB) ListChannelRoleOverrides(ctx context.Context, channelID int64) ([]ChannelRoleOverride, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT r.id, r.name, r.position, r.permissions, COALESCE(o.allow, 0), COALESCE(o.deny, 0) FROM roles r @@ -262,7 +262,7 @@ func (d *DB) GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.QueryContext(ctx, query, args...) + rows, err := d.reader.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetChannelTypes query: %w", err) } diff --git a/Server/db/db.go b/Server/db/db.go index 293e2f82..0ee2271c 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -5,29 +5,101 @@ package db import ( "context" "database/sql" + "errors" "fmt" + "runtime" + "strings" + "sync/atomic" "github.com/owncord/server/db/dbgen" "github.com/owncord/server/migrations" _ "modernc.org/sqlite" // register the sqlite3 driver ) -// DB wraps *sql.DB and exposes the subset of methods needed by the server. +// DB wraps the underlying SQLite pools and exposes the subset of methods +// needed by the server. // // q is the sqlc-generated query layer (db/dbgen). Query method bodies delegate // to it — sqlc is the source of truth for the SQL text and parameter binding // (verified in CI by `make sqlc-verify`), while this package keeps the stable // public API and the domain model types the rest of the server consumes. // Migration is incremental (decision D2); methods not yet delegated still run -// their raw SQL directly against sqlDB. +// their raw SQL directly against writer/reader. type DB struct { - sqlDB *sql.DB - q *dbgen.Queries + // writer is a single-connection pool that owns every statement that can + // mutate the database: INSERT/UPDATE/DELETE (including RETURNING forms), + // transactions, migrations, ANALYZE/VACUUM and PRAGMA writes. Pinning + // writes to one connection makes concurrent writers queue on the Go side + // instead of colliding on SQLite's single write lock. + writer *sql.DB + + // reader is a multi-connection pool serving read-only statements + // (SELECT / PRAGMA reads). Under WAL, readers run concurrently with each + // other and with the writer, which is the point of the split. For + // in-memory databases reader and writer are the same handle. + reader *sql.DB + + q *dbgen.Queries + + // auditWriter, when installed via SetAuditWriter (main.go server + // startup only), turns WriteAudit calls backed by this DB into + // non-blocking enqueues. Nil (the default) keeps audit writes + // synchronous — the token CLI and tests rely on that. + auditWriter atomic.Pointer[AuditWriter] +} + +// filePragmas are the per-connection PRAGMAs applied to every file-backed +// connection via `_pragma=` DSN parameters (modernc.org/sqlite executes each +// one in newConn, busy_timeout first). They MUST be in the DSN rather than +// Exec'd after Open: with a pool larger than one connection an Exec'd PRAGMA +// lands on one arbitrary connection and every other connection would silently +// run with foreign_keys=OFF. +const filePragmas = "_pragma=busy_timeout(5000)" + // wait up to 5s for the write lock instead of failing instantly + "&_pragma=journal_mode(WAL)" + // WAL: readers don't block the writer and vice versa + "&_pragma=foreign_keys(1)" + // enforce foreign key constraints + "&_pragma=synchronous(NORMAL)" + // performance tuning, safe with WAL + "&_pragma=temp_store(MEMORY)" + + "&_pragma=mmap_size(268435456)" + + "&_pragma=cache_size(-64000)" + +// isMemoryPath reports whether path names an in-memory database +// (":memory:", "file::memory:" or any URI carrying mode=memory). +func isMemoryPath(path string) bool { + return strings.Contains(path, ":memory:") || strings.Contains(path, "mode=memory") } // Open opens (or creates) a SQLite database at path, enables WAL mode and // foreign key enforcement, and returns a ready-to-use DB. +// +// Two modes: +// +// - In-memory databases keep the historical single-handle behavior: one +// *sql.DB pinned to a single connection with the PRAGMAs Exec'd once. +// A one-connection pool makes DSN PRAGMAs unnecessary, all callers share +// the same in-memory state, and connection-scoped PRAGMA toggles in tests +// (e.g. temporarily disabling foreign_keys) behave deterministically. +// In this mode reader and writer are the same handle. +// +// - File-backed databases get a reader/writer pool split. Both pools carry +// the PRAGMAs in the DSN so every physical connection is configured +// identically. The writer is additionally opened with _txlock=immediate +// so explicit transactions take the write lock up front instead of +// failing with SQLITE_BUSY on upgrade. +// +// Path assumptions (file mode): path is either a plain filesystem path or an +// existing file: URI. It must not contain '?', '#' or '%' characters — the +// path is embedded in a file: URI without escaping. cfg.Database.Path is a +// plain path (default "data/chatserver.db"), which satisfies this. func Open(path string) (*DB, error) { + if isMemoryPath(path) { + return openMemory(path) + } + return openFile(path) +} + +// openMemory preserves the pre-split behavior exactly: a single connection +// with PRAGMAs applied by Exec. reader == writer. +func openMemory(path string) (*DB, error) { sqlDB, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("opening sqlite db: %w", err) @@ -39,49 +111,169 @@ func Open(path string) (*DB, error) { return nil, fmt.Errorf("pinging sqlite db: %w", err) } - // SQLite only allows one writer at a time. Pin to a single connection - // so concurrent goroutines queue on the Go side rather than getting - // SQLITE_BUSY. For :memory: databases this also ensures all callers - // share the same in-memory state. + // A single connection ensures all callers share the same in-memory state + // and makes the Exec'd PRAGMAs below apply to every future statement. sqlDB.SetMaxOpenConns(1) - // Enable WAL mode for better concurrent read performance. - if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("enabling WAL mode: %w", err) + // The same PRAGMA set as filePragmas, Exec'd because there is exactly one + // connection to configure. journal_mode is a no-op for :memory: (SQLite + // reports "memory") but is kept for symmetry with file mode. + for _, p := range []struct{ name, stmt string }{ + {"enabling WAL mode", "PRAGMA journal_mode=WAL;"}, + {"setting busy_timeout", "PRAGMA busy_timeout=5000;"}, + {"enabling foreign keys", "PRAGMA foreign_keys=ON;"}, + {"setting synchronous mode", "PRAGMA synchronous=NORMAL;"}, + {"setting temp_store", "PRAGMA temp_store=MEMORY;"}, + {"setting mmap_size", "PRAGMA mmap_size=268435456;"}, + {"setting cache_size", "PRAGMA cache_size=-64000;"}, + } { + if _, err := sqlDB.Exec(p.stmt); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("%s: %w", p.name, err) + } } - // Wait up to 5 seconds for the write lock instead of failing instantly. - if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("setting busy_timeout: %w", err) + return newDB(sqlDB, sqlDB), nil +} + +// openFile opens the writer and reader pools for a file-backed database. +func openFile(path string) (*DB, error) { + base := path + if !strings.HasPrefix(base, "file:") { + base = "file:" + base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + dsn := base + sep + filePragmas + + // Writer: one connection so concurrent writes queue on the Go side, with + // BEGIN IMMEDIATE transactions (see Open doc). + writer, err := sql.Open("sqlite", dsn+"&_txlock=immediate") + if err != nil { + return nil, fmt.Errorf("opening sqlite db: %w", err) + } + writer.SetMaxOpenConns(1) + + // Verify the connection is actually usable (this also creates the file, + // so the reader below never races file creation). + if err := writer.Ping(); err != nil { + _ = writer.Close() + return nil, fmt.Errorf("pinging sqlite db: %w", err) } - // Enforce foreign key constraints. - if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("enabling foreign keys: %w", err) + // Reader: sized for concurrent request handling. Idle == open so warm + // connections (and their page caches) are kept rather than churned. + reader, err := sql.Open("sqlite", dsn) + if err != nil { + _ = writer.Close() + return nil, fmt.Errorf("opening sqlite reader pool: %w", err) + } + readConns := max(4, runtime.NumCPU()) + reader.SetMaxOpenConns(readConns) + reader.SetMaxIdleConns(readConns) + if err := reader.Ping(); err != nil { + _ = reader.Close() + _ = writer.Close() + return nil, fmt.Errorf("pinging sqlite reader pool: %w", err) } - // Performance tuning (safe with WAL mode). - if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("setting synchronous mode: %w", err) - } - if _, err := sqlDB.Exec("PRAGMA temp_store=MEMORY;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("setting temp_store: %w", err) - } - if _, err := sqlDB.Exec("PRAGMA mmap_size=268435456;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("setting mmap_size: %w", err) - } - if _, err := sqlDB.Exec("PRAGMA cache_size=-64000;"); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("setting cache_size: %w", err) - } + return newDB(writer, reader), nil +} - return &DB{sqlDB: sqlDB, q: dbgen.New(sqlDB)}, nil +// newDB assembles a DB whose sqlc query layer routes through dbtx. +func newDB(writer, reader *sql.DB) *DB { + return &DB{ + writer: writer, + reader: reader, + q: dbgen.New(&dbtx{writer: writer, reader: reader}), + } +} + +// dbtx routes statements between the reader and writer pools. It satisfies +// sqlc's DBTX interface (db/dbgen/db.go) so the generated query layer picks +// the correct pool per statement without touching generated code, and it +// backs the DB.QueryContext/QueryRowContext wrappers for the same reason. +// +// Routing table: +// +// - ExecContext → writer. +// - PrepareContext → writer. sqlc's generated code in this repo prepares +// nothing through DBTX (only *sql.Tx.PrepareContext inside the batch +// persisters, which already run on writer transactions); this method +// exists for interface completeness. Tradeoff: if a future caller +// prepared a hot SELECT here it would run serialized on the writer. +// - QueryContext / QueryRowContext → reader, but only when the statement +// is provably read-only (isReadOnlySQL). sqlc routes +// INSERT/UPDATE/DELETE ... RETURNING statements through +// QueryRowContext/QueryContext (messages, attachments), and those writes +// must stay on the single writer connection; anything not provably +// read-only conservatively falls back to the writer. +// +// For in-memory databases writer == reader, so routing degenerates to the +// historical single-handle behavior. +type dbtx struct { + writer *sql.DB + reader *sql.DB +} + +func (t *dbtx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return t.writer.ExecContext(ctx, query, args...) +} + +func (t *dbtx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return t.writer.PrepareContext(ctx, query) +} + +func (t *dbtx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return t.pool(query).QueryContext(ctx, query, args...) +} + +func (t *dbtx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return t.pool(query).QueryRowContext(ctx, query, args...) +} + +// pool selects the reader for read-only statements and the writer otherwise. +func (t *dbtx) pool(query string) *sql.DB { + if isReadOnlySQL(query) { + return t.reader + } + return t.writer +} + +// isReadOnlySQL reports whether the statement's leading keyword — after +// skipping whitespace and `--` line comments — is SELECT or PRAGMA. Comment +// skipping matters because sqlc-generated SQL starts with a `-- name: ...` +// line. Anything unrecognized (including `/* */` block comments, which this +// package does not use) is conservatively treated as a write. +func isReadOnlySQL(query string) bool { + s := query + for { + s = strings.TrimLeft(s, " \t\r\n") + if !strings.HasPrefix(s, "--") { + break + } + nl := strings.IndexByte(s, '\n') + if nl < 0 { + return false // comment-only "statement" — let the writer reject it + } + s = s[nl+1:] + } + return hasKeywordPrefix(s, "SELECT") || hasKeywordPrefix(s, "PRAGMA") +} + +// hasKeywordPrefix reports whether s starts with the keyword (ASCII +// case-insensitive) followed by a non-identifier character or end of input. +func hasKeywordPrefix(s, keyword string) bool { + if len(s) < len(keyword) || !strings.EqualFold(s[:len(keyword)], keyword) { + return false + } + if len(s) == len(keyword) { + return true + } + c := s[len(keyword)] + return c != '_' && (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') } // Migrate runs all SQL migration files from the embedded migrations FS in @@ -89,37 +281,68 @@ func Open(path string) (*DB, error) { // MigrateFS (defined in migrate.go) which maintains the schema_versions // tracking table. func Migrate(database *DB) error { - return MigrateFS(database, migrations.FS) + if err := MigrateFS(database, migrations.FS); err != nil { + return err + } + // Refresh the query planner's statistics once per startup so newly created + // indexes (e.g. migration 019) are actually chosen. Close() keeps them + // current afterwards via PRAGMA optimize. ANALYZE writes sqlite_stat rows, + // so it runs on the writer. + if _, err := database.writer.Exec("ANALYZE;"); err != nil { + return fmt.Errorf("running ANALYZE after migrations: %w", err) + } + return nil } -// Close releases the underlying database connection. +// Close releases the underlying database connections (both pools). func (d *DB) Close() error { // Run PRAGMA optimize to analyze and update query planner statistics. - _, _ = d.sqlDB.Exec("PRAGMA optimize;") - return d.sqlDB.Close() + // It may write statistics, so it runs on the writer. + _, _ = d.writer.Exec("PRAGMA optimize;") + var readerErr error + if d.reader != d.writer { + readerErr = d.reader.Close() + } + return errors.Join(d.writer.Close(), readerErr) } // QueryRowContext executes a query that returns at most one row, with context. +// Read-only statements run on the reader pool; anything else on the writer. func (d *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { - return d.sqlDB.QueryRowContext(ctx, query, args...) + return d.routePool(query).QueryRowContext(ctx, query, args...) } // ExecContext executes a query that doesn't return rows, with context. +// Always runs on the writer. func (d *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { - return d.sqlDB.ExecContext(ctx, query, args...) + return d.writer.ExecContext(ctx, query, args...) } // QueryContext executes a query that returns multiple rows, with context. +// Read-only statements run on the reader pool; anything else on the writer. func (d *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return d.sqlDB.QueryContext(ctx, query, args...) + return d.routePool(query).QueryContext(ctx, query, args...) +} + +// routePool mirrors dbtx.pool for the public wrapper methods. +func (d *DB) routePool(query string) *sql.DB { + if isReadOnlySQL(query) { + return d.reader + } + return d.writer } // BeginTx starts a database transaction with context and options. +// Transactions always run on the writer (file-backed writers BEGIN IMMEDIATE +// via _txlock, so the write lock is taken up front). func (d *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { - return d.sqlDB.BeginTx(ctx, opts) + return d.writer.BeginTx(ctx, opts) } -// SQLDb returns the underlying *sql.DB for cases requiring direct access. +// SQLDb returns the underlying writer *sql.DB for cases requiring direct +// access. It is the escape hatch for statements the wrappers can't route — +// notably PRAGMA wal_checkpoint(TRUNCATE) in the admin backup handler, which +// must run on the writer to checkpoint the WAL it just stopped appending to. func (d *DB) SQLDb() *sql.DB { - return d.sqlDB + return d.writer } diff --git a/Server/db/db_test.go b/Server/db/db_test.go index 16dbdaab..1675b952 100644 --- a/Server/db/db_test.go +++ b/Server/db/db_test.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "testing" "testing/fstest" "time" @@ -203,10 +204,11 @@ func TestMigrateCreatesIndexes(t *testing.T) { } expectedIndexes := []string{ - "idx_sessions_token", "idx_messages_channel", - "idx_invites_code", "idx_audit_timestamp", + "idx_attachments_message", + "idx_channel_overrides_role", + "idx_messages_pinned", } for _, idx := range expectedIndexes { @@ -223,6 +225,52 @@ func TestMigrateCreatesIndexes(t *testing.T) { } }) } + + // Migration 019 drops the duplicate of the channel_overrides UNIQUE + // auto-index; migration 020 drops the duplicates of the sessions.token + // and invites.code UNIQUE auto-indexes. + droppedIndexes := []string{ + "idx_channel_overrides_channel_role", + "idx_sessions_token", + "idx_invites_code", + } + for _, idx := range droppedIndexes { + t.Run(idx+" dropped", func(t *testing.T) { + var name string + err := database.QueryRowContext(context.Background(), + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + idx, + ).Scan(&name) + if err == nil { + t.Errorf("%s still exists after migrations", idx) + } else if err != sql.ErrNoRows { + t.Errorf("query error: %v", err) + } + }) + } +} + +func TestMigrateScopesFTSUpdateTriggerToContent(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Migration 019 recreates messages_au as AFTER UPDATE OF content, so + // pin/soft-delete updates stop paying for a full FTS reindex. The trigger + // must exist and be scoped to content updates. + var sqlText string + err := database.QueryRowContext(context.Background(), + "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='messages_au'", + ).Scan(&sqlText) + if err != nil { + t.Fatalf("messages_au trigger not found after migration: %v", err) + } + upper := strings.ToUpper(sqlText) + if !strings.Contains(upper, "AFTER UPDATE OF CONTENT") { + t.Errorf("messages_au = %q, want AFTER UPDATE OF content scope", sqlText) + } } func TestCloseIdempotent(t *testing.T) { diff --git a/Server/db/dbgen/dm.sql.go b/Server/db/dbgen/dm.sql.go index 88e4ab24..3394d246 100644 --- a/Server/db/dbgen/dm.sql.go +++ b/Server/db/dbgen/dm.sql.go @@ -50,6 +50,33 @@ func (q *Queries) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]i return items, nil } +const getUserDMChannelIDs = `-- name: GetUserDMChannelIDs :many +SELECT channel_id FROM dm_open_state WHERE user_id = ? +` + +func (q *Queries) GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, getUserDMChannelIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var channel_id int64 + if err := rows.Scan(&channel_id); err != nil { + return nil, err + } + items = append(items, channel_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserDMChannels = `-- name: GetUserDMChannels :many SELECT c.id AS channel_id, @@ -60,8 +87,11 @@ SELECT lm.id AS last_message_id, COALESCE(lm.content, '') AS last_message, COALESCE(lm.timestamp, '') AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = 0 THEN 1 END) AS unread_count + (SELECT COUNT(*) FROM messages mu + WHERE mu.channel_id = c.id AND mu.deleted = 0 + AND mu.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = dos.user_id), 0) + ) AS unread_count FROM dm_open_state dos JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? @@ -69,17 +99,13 @@ JOIN users u ON u.id = dp.user_id LEFT JOIN messages lm ON lm.id = ( SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 ) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? WHERE dos.user_id = ? -GROUP BY c.id ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC ` type GetUserDMChannelsParams struct { UserID int64 `json:"userId"` UserID_2 int64 `json:"userId2"` - UserID_3 int64 `json:"userId3"` } type GetUserDMChannelsRow struct { @@ -95,7 +121,7 @@ type GetUserDMChannelsRow struct { } func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) { - rows, err := q.db.QueryContext(ctx, getUserDMChannels, arg.UserID, arg.UserID_2, arg.UserID_3) + rows, err := q.db.QueryContext(ctx, getUserDMChannels, arg.UserID, arg.UserID_2) if err != nil { return nil, err } diff --git a/Server/db/dbgen/messages.sql.go b/Server/db/dbgen/messages.sql.go index a4c1153e..9ad36dd1 100644 --- a/Server/db/dbgen/messages.sql.go +++ b/Server/db/dbgen/messages.sql.go @@ -10,8 +10,9 @@ import ( "database/sql" ) -const createMessage = `-- name: CreateMessage :execresult +const createMessage = `-- name: CreateMessage :one INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?) +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp ` type CreateMessageParams struct { @@ -21,17 +22,31 @@ type CreateMessageParams struct { ReplyTo *int64 `json:"replyTo"` } -func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (sql.Result, error) { - return q.db.ExecContext(ctx, createMessage, +func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { + row := q.db.QueryRowContext(ctx, createMessage, arg.ChannelID, arg.UserID, arg.Content, arg.ReplyTo, ) + var i Message + err := row.Scan( + &i.ID, + &i.ChannelID, + &i.UserID, + &i.Content, + &i.ReplyTo, + &i.EditedAt, + &i.Deleted, + &i.Pinned, + &i.Timestamp, + ) + return i, err } -const editMessageContent = `-- name: EditMessageContent :exec +const editMessageContent = `-- name: EditMessageContent :one UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ? +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp ` type EditMessageContentParams struct { @@ -39,20 +54,33 @@ type EditMessageContentParams struct { ID int64 `json:"id"` } -func (q *Queries) EditMessageContent(ctx context.Context, arg EditMessageContentParams) error { - _, err := q.db.ExecContext(ctx, editMessageContent, arg.Content, arg.ID) - return err +func (q *Queries) EditMessageContent(ctx context.Context, arg EditMessageContentParams) (Message, error) { + row := q.db.QueryRowContext(ctx, editMessageContent, arg.Content, arg.ID) + var i Message + err := row.Scan( + &i.ID, + &i.ChannelID, + &i.UserID, + &i.Content, + &i.ReplyTo, + &i.EditedAt, + &i.Deleted, + &i.Pinned, + &i.Timestamp, + ) + return i, err } const getChannelUnreadCounts = `-- name: GetChannelUnreadCounts :many SELECT c.id, - COALESCE(MAX(m.id), 0) AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread + (SELECT COALESCE(MAX(m.id), 0) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0) AS last_msg_id, + (SELECT COUNT(*) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0 + AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0 -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? -WHERE c.type = 'text' -GROUP BY c.id +WHERE c.type IN ('text', 'announcement') ` type GetChannelUnreadCountsRow struct { diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index f16c5fbc..00e25015 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -29,7 +29,7 @@ type Querier interface { CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error CreateChannel(ctx context.Context, arg CreateChannelParams) (sql.Result, error) CreateInvite(ctx context.Context, arg CreateInviteParams) error - CreateMessage(ctx context.Context, arg CreateMessageParams) (sql.Result, error) + CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) DeleteChannel(ctx context.Context, id int64) error DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error @@ -40,7 +40,7 @@ type Querier interface { DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error) DeleteSessionByToken(ctx context.Context, token string) error DisablePlugin(ctx context.Context, id int64) error - EditMessageContent(ctx context.Context, arg EditMessageContentParams) error + EditMessageContent(ctx context.Context, arg EditMessageContentParams) (Message, error) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error) EnablePlugin(ctx context.Context, id int64) error EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error @@ -82,6 +82,7 @@ type Querier interface { GetSetting(ctx context.Context, key string) (string, error) GetUserByID(ctx context.Context, id int64) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error) + GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index 207e97f9..a92c73d4 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -37,7 +37,7 @@ type DMUser struct { // prevent a TOCTOU race where two concurrent requests both see ErrNoRows and // each create a separate DM channel for the same user pair. func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*Channel, bool, error) { - tx, err := d.sqlDB.BeginTx(ctx, &sql.TxOptions{ + tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{ Isolation: sql.LevelSerializable, }) if err != nil { @@ -136,8 +136,12 @@ func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) ( // (dm_open_state only contains rows for DM channels), and the explicit // "c.type = 'dm'" predicate in the JOIN provides a defensive second check. // No additional channel-type validation is needed at the Go layer. +// +// The unread count is a correlated subquery range-scanning +// idx_messages_channel per DM, replacing the old LEFT JOIN messages fan-out +// that touched every message row in every open DM. func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT c.id AS channel_id, u.id AS recipient_id, @@ -147,8 +151,11 @@ func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelIn lm.id AS last_message_id, COALESCE(lm.content, '') AS last_message, COALESCE(lm.timestamp, '') AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = 0 THEN 1 END) AS unread_count + (SELECT COUNT(*) FROM messages mu + WHERE mu.channel_id = c.id AND mu.deleted = 0 + AND mu.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = dos.user_id), 0) + ) AS unread_count FROM dm_open_state dos JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? @@ -156,12 +163,9 @@ func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelIn LEFT JOIN messages lm ON lm.id = ( SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 ) - LEFT JOIN messages m_unread ON m_unread.channel_id = c.id - LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? WHERE dos.user_id = ? - GROUP BY c.id ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC`, - userID, userID, userID, + userID, userID, ) if err != nil { return nil, fmt.Errorf("GetUserDMChannels: %w", err) @@ -241,6 +245,18 @@ func (d *DB) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool return true, nil } +// GetUserDMChannelIDs returns the channel IDs of all DMs the user has open. +// It reads only the dm_open_state primary key, so callers that just need the +// ID set (access computation, search scoping) skip the recipient/preview/ +// unread work GetUserDMChannels pays for. +func (d *DB) GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) { + ids, err := d.q.GetUserDMChannelIDs(ctx, userID) + if err != nil { + return nil, fmt.Errorf("GetUserDMChannelIDs: %w", err) + } + return ids, nil +} + // GetDMParticipantIDs returns all participant user IDs for a DM channel. func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { ids, err := d.q.GetDMParticipantIDs(ctx, channelID) @@ -253,7 +269,7 @@ func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, // GetDMRecipient returns the other participant in a DM channel. func (d *DB) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*User, error) { var recipientID int64 - err := d.sqlDB.QueryRowContext(ctx, + err := d.reader.QueryRowContext(ctx, `SELECT user_id FROM dm_participants WHERE channel_id = ? AND user_id != ? LIMIT 1`, diff --git a/Server/db/dm_queries_test.go b/Server/db/dm_queries_test.go index c486dfdc..c69aaba3 100644 --- a/Server/db/dm_queries_test.go +++ b/Server/db/dm_queries_test.go @@ -78,6 +78,64 @@ func TestGetOrCreateDMChannel_IdempotentReversedOrder(t *testing.T) { } } +// ─── GetUserDMChannelIDs ──────────────────────────────────────────────────── + +// The ID-only lookup backs access computation (ws replay filtering, search +// scoping) — it must track dm_open_state exactly: present while open, gone +// after close, per user. +func TestGetUserDMChannelIDs(t *testing.T) { + database := openMigratedMemory(t) + user1 := seedUser(t, database, "alice") + user2 := seedUser(t, database, "bob") + user3 := seedUser(t, database, "carol") + + ch12, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) + if err != nil { + t.Fatalf("GetOrCreateDMChannel(u1,u2): %v", err) + } + ch13, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user3) + if err != nil { + t.Fatalf("GetOrCreateDMChannel(u1,u3): %v", err) + } + + ids, err := database.GetUserDMChannelIDs(context.Background(), user1) + if err != nil { + t.Fatalf("GetUserDMChannelIDs: %v", err) + } + got := make(map[int64]bool, len(ids)) + for _, id := range ids { + got[id] = true + } + if len(ids) != 2 || !got[ch12.ID] || !got[ch13.ID] { + t.Errorf("user1 IDs = %v, want {%d, %d}", ids, ch12.ID, ch13.ID) + } + + // user2 only participates in one DM. + ids2, err := database.GetUserDMChannelIDs(context.Background(), user2) + if err != nil { + t.Fatalf("GetUserDMChannelIDs(user2): %v", err) + } + if len(ids2) != 1 || ids2[0] != ch12.ID { + t.Errorf("user2 IDs = %v, want [%d]", ids2, ch12.ID) + } + + // Closing a DM removes it from the caller's set only. + if err := database.CloseDM(context.Background(), user1, ch12.ID); err != nil { + t.Fatalf("CloseDM: %v", err) + } + ids, err = database.GetUserDMChannelIDs(context.Background(), user1) + if err != nil { + t.Fatalf("GetUserDMChannelIDs after close: %v", err) + } + if len(ids) != 1 || ids[0] != ch13.ID { + t.Errorf("user1 IDs after close = %v, want [%d]", ids, ch13.ID) + } + ids2, _ = database.GetUserDMChannelIDs(context.Background(), user2) + if len(ids2) != 1 || ids2[0] != ch12.ID { + t.Errorf("user2 IDs after user1's close = %v, want [%d]", ids2, ch12.ID) + } +} + func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") diff --git a/Server/db/event_queries.go b/Server/db/event_queries.go index 462f320f..3ee2e785 100644 --- a/Server/db/event_queries.go +++ b/Server/db/event_queries.go @@ -19,7 +19,7 @@ import ( // seq always matches the wrapped-payload seq, even if the persister drops // some events under load. func (d *DB) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error { - _, err := d.sqlDB.ExecContext(ctx, + _, err := d.writer.ExecContext(ctx, `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`, seq, eventType, channelID, payload, ) @@ -29,9 +29,66 @@ func (d *DB) PersistEvent(ctx context.Context, seq int64, eventType string, chan return nil } +// PersistEvents appends a batch of events in a single transaction with one +// prepared insert, so the persister's flush pays for one fsync instead of one +// per event. CreatedAt on the input rows is ignored (the column defaults). +// +// Best-effort semantics are preserved: if the batched transaction fails (e.g. +// one row has a duplicate seq), it falls back to per-row inserts so the good +// rows still land. Returns the number of rows persisted and, when any row was +// lost, the first per-row error. +func (d *DB) PersistEvents(ctx context.Context, events []PersistedEvent) (int, error) { + if len(events) == 0 { + return 0, nil + } + if err := d.persistEventsTx(ctx, events); err == nil { + return len(events), nil + } + // Fallback: insert rows individually so one bad row doesn't drop the batch. + persisted := 0 + var firstErr error + for _, e := range events { + if err := d.PersistEvent(ctx, e.Seq, e.EventType, e.ChannelID, e.Payload); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + persisted++ + } + return persisted, firstErr +} + +// persistEventsTx inserts all events inside one transaction; any failure +// rolls the whole batch back. +func (d *DB) persistEventsTx(ctx context.Context, events []PersistedEvent) error { + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("PersistEvents begin tx: %w", err) + } + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`, + ) + if err != nil { + _ = tx.Rollback() + return fmt.Errorf("PersistEvents prepare: %w", err) + } + defer func() { _ = stmt.Close() }() + for _, e := range events { + if _, err := stmt.ExecContext(ctx, e.Seq, e.EventType, e.ChannelID, e.Payload); err != nil { + _ = tx.Rollback() + return fmt.Errorf("PersistEvents insert seq %d: %w", e.Seq, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("PersistEvents commit: %w", err) + } + return nil +} + // GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC. func (d *DB) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]PersistedEvent, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT seq, event_type, channel_id, payload, created_at FROM events WHERE seq > ? @@ -52,7 +109,7 @@ func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, chan // Build IN clause manually since database/sql does not expand slices. if len(channelIDs) == 0 { // Only global broadcasts. - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT seq, event_type, channel_id, payload, created_at FROM events WHERE seq > ? AND channel_id = 0 @@ -85,7 +142,7 @@ func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, chan LIMIT ?`, strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.QueryContext(ctx, query, args...) + rows, err := d.reader.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) } @@ -96,7 +153,7 @@ func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, chan // GetMaxEventSeq returns the largest seq in the events table, or 0 if empty. func (d *DB) GetMaxEventSeq(ctx context.Context) (int64, error) { var maxSeq sql.NullInt64 - err := d.sqlDB.QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq) + err := d.reader.QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq) if err != nil { return 0, fmt.Errorf("GetMaxEventSeq: %w", err) } @@ -108,7 +165,7 @@ func (d *DB) GetMaxEventSeq(ctx context.Context) (int64, error) { // PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted. func (d *DB) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - res, err := d.sqlDB.ExecContext(ctx, + res, err := d.writer.ExecContext(ctx, `DELETE FROM events WHERE created_at < ?`, cutoff.UTC().Format("2006-01-02 15:04:05"), ) diff --git a/Server/db/event_queries_test.go b/Server/db/event_queries_test.go index a2c92d14..69e2930f 100644 --- a/Server/db/event_queries_test.go +++ b/Server/db/event_queries_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" "time" + + "github.com/owncord/server/db" ) // The events table backs cold-tier reconnect replay: when a client's last_seq @@ -51,6 +53,86 @@ func TestPersistEvent_AndGetEventsSince(t *testing.T) { } } +func TestPersistEvents_BatchInsertsAll(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + batch := []db.PersistedEvent{ + {Seq: 1, EventType: "chat_message", ChannelID: 10, Payload: []byte(`{"n":1}`)}, + {Seq: 2, EventType: "chat_message", ChannelID: 10, Payload: []byte(`{"n":2}`)}, + {Seq: 3, EventType: "presence", ChannelID: 0, Payload: []byte(`{"n":3}`)}, + } + persisted, err := database.PersistEvents(ctx, batch) + if err != nil { + t.Fatalf("PersistEvents: %v", err) + } + if persisted != 3 { + t.Fatalf("persisted = %d, want 3", persisted) + } + + rows, err := database.GetEventsSince(ctx, 0, 100) + if err != nil { + t.Fatalf("GetEventsSince: %v", err) + } + if len(rows) != 3 { + t.Fatalf("stored %d events, want 3", len(rows)) + } + for i, e := range rows { + if e.Seq != batch[i].Seq || e.EventType != batch[i].EventType || e.ChannelID != batch[i].ChannelID { + t.Errorf("row[%d] = %+v, want seq/type/channel of %+v", i, e, batch[i]) + } + } +} + +func TestPersistEvents_EmptyBatchIsNoop(t *testing.T) { + database := newMigratedTestDB(t) + + persisted, err := database.PersistEvents(context.Background(), nil) + if err != nil { + t.Fatalf("PersistEvents(nil): %v", err) + } + if persisted != 0 { + t.Errorf("persisted = %d, want 0", persisted) + } +} + +// One bad row must not drop the batch: the tx fails (duplicate seq is the +// PRIMARY KEY), and the per-row fallback still lands the good rows — +// best-effort semantics identical to the old per-event loop. +func TestPersistEvents_FallbackKeepsGoodRowsOnBadBatch(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + if err := database.PersistEvent(ctx, 2, "e", 0, []byte(`{}`)); err != nil { + t.Fatalf("PersistEvent: %v", err) + } + + persisted, err := database.PersistEvents(ctx, []db.PersistedEvent{ + {Seq: 1, EventType: "e", ChannelID: 0, Payload: []byte(`{}`)}, + {Seq: 2, EventType: "e", ChannelID: 0, Payload: []byte(`{}`)}, // duplicate — fails + {Seq: 3, EventType: "e", ChannelID: 0, Payload: []byte(`{}`)}, + }) + if persisted != 2 { + t.Errorf("persisted = %d, want 2 (rows 1 and 3 via fallback)", persisted) + } + if err == nil { + t.Error("PersistEvents must report the lost row's error") + } + + rows, qErr := database.GetEventsSince(ctx, 0, 100) + if qErr != nil { + t.Fatalf("GetEventsSince: %v", qErr) + } + if len(rows) != 3 { + t.Fatalf("stored %d events, want 3 (pre-existing 2 plus fallback 1 and 3)", len(rows)) + } + for i, want := range []int64{1, 2, 3} { + if rows[i].Seq != want { + t.Errorf("row[%d].Seq = %d, want %d", i, rows[i].Seq, want) + } + } +} + func TestGetEventsSince_RespectsLimit(t *testing.T) { database := newMigratedTestDB(t) ctx := context.Background() diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index f0c0e7f8..a6d3d74a 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -48,16 +48,27 @@ func sanitizeFTSQuery(q string) string { // CreateMessage inserts a new message and returns the assigned ID. // Content should already be sanitized before calling this function. func (d *DB) CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) { - res, err := d.q.CreateMessage(ctx, dbgen.CreateMessageParams{ + m, err := d.CreateMessageReturning(ctx, channelID, userID, content, replyTo) + if err != nil { + return 0, err + } + return m.ID, nil +} + +// CreateMessageReturning inserts a new message and returns the full inserted +// row via RETURNING, so hot paths (the send fan-out needs the DB-assigned +// timestamp) don't re-read the row they just wrote. +func (d *DB) CreateMessageReturning(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (*Message, error) { + m, err := d.q.CreateMessage(ctx, dbgen.CreateMessageParams{ ChannelID: channelID, UserID: userID, Content: content, ReplyTo: replyTo, }) if err != nil { - return 0, fmt.Errorf("CreateMessage: %w", err) + return nil, fmt.Errorf("CreateMessage: %w", err) } - return res.LastInsertId() + return messageFromGen(m), nil } // GetMessage returns the message with the given ID, or nil if not found. @@ -81,7 +92,7 @@ func (d *DB) GetMessages(ctx context.Context, channelID, before int64, limit int err error ) if before > 0 { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -91,7 +102,7 @@ func (d *DB) GetMessages(ctx context.Context, channelID, before int64, limit int channelID, before, limit, ) } else { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -123,27 +134,29 @@ func (d *DB) GetMessages(ctx context.Context, channelID, before int64, limit int return msgs, nil } -// EditMessage updates the content and sets edited_at on the message. +// EditMessage updates the content and sets edited_at on the message, and +// returns the updated row via RETURNING so callers don't re-read it. // Returns an error if the message does not exist or userID does not match the owner. -func (d *DB) EditMessage(ctx context.Context, id, userID int64, content string) error { +func (d *DB) EditMessage(ctx context.Context, id, userID int64, content string) (*Message, error) { msg, err := d.GetMessage(ctx, id) if err != nil { - return err + return nil, err } if msg == nil { - return fmt.Errorf("EditMessage: message %d: %w", id, ErrNotFound) + return nil, fmt.Errorf("EditMessage: message %d: %w", id, ErrNotFound) } if msg.UserID != userID { - return fmt.Errorf("EditMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) + return nil, fmt.Errorf("EditMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } - if err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{ + updated, err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{ Content: content, ID: id, - }); err != nil { - return fmt.Errorf("EditMessage: %w", err) + }) + if err != nil { + return nil, fmt.Errorf("EditMessage: %w", err) } - return nil + return messageFromGen(updated), nil } // DeleteMessage performs a soft delete (sets deleted=1) on the message. @@ -230,7 +243,7 @@ func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, ) if channelID != nil { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -241,7 +254,7 @@ func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, query, *channelID, limit, ) } else { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -301,7 +314,7 @@ func (d *DB) SearchMessagesInChannels(ctx context.Context, query string, channel } args = append(args, limit) - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, fmt.Sprintf( `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f @@ -345,7 +358,7 @@ func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, lim err error ) if before > 0 { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -354,7 +367,7 @@ func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, lim channelID, before, limit, ) } else { - rows, err = d.sqlDB.QueryContext(ctx, + rows, err = d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -400,7 +413,7 @@ func (d *DB) getReactionsBatch(ctx context.Context, msgIDs []int64, requestingUs ) args = append([]any{requestingUserID}, args...) - rows, err := d.sqlDB.QueryContext(ctx, query, args...) + rows, err := d.reader.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("getReactionsBatch: %w", err) } @@ -436,17 +449,21 @@ func (d *DB) UpdateReadState(ctx context.Context, userID, channelID, lastReadMes } // GetChannelUnreadCounts returns per-channel unread counts and last message IDs -// for a given user. Only text channels with at least one message are included. +// for a given user. Text and announcement channels are included, with 0,0 for +// channels that have no messages. Correlated subqueries range-scan +// idx_messages_channel per channel instead of the old LEFT JOIN fan-out that +// touched every message row on every WS connect. func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]ChannelUnread, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT c.id, - COALESCE(MAX(m.id), 0) AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread + (SELECT COALESCE(MAX(m.id), 0) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0) AS last_msg_id, + (SELECT COUNT(*) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0 + AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread FROM channels c - LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0 - LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? - WHERE c.type IN ('text', 'announcement') - GROUP BY c.id`, + WHERE c.type IN ('text', 'announcement')`, userID, ) if err != nil { @@ -472,7 +489,7 @@ func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int6 // GetLatestMessageID returns the highest message ID in a channel, or 0 if empty. func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) { var id int64 - err := d.sqlDB.QueryRowContext(ctx, + err := d.reader.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0`, channelID, ).Scan(&id) @@ -485,7 +502,7 @@ func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, er // GetPinnedMessages returns all pinned messages in a channel in the API response shape, // including user object, reactions (with me flag), and attachments. func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go index 12a8b8da..07e49910 100644 --- a/Server/db/message_queries_test.go +++ b/Server/db/message_queries_test.go @@ -211,9 +211,17 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { id, _ := database.CreateMessage(context.Background(), chID, userID, "original", nil) - if err := database.EditMessage(context.Background(), id, userID, "updated"); err != nil { + updated, err := database.EditMessage(context.Background(), id, userID, "updated") + if err != nil { t.Fatalf("EditMessage: %v", err) } + // The RETURNING row must reflect the write without a re-read. + if updated == nil || updated.Content != "updated" { + t.Errorf("returned row Content = %+v, want 'updated'", updated) + } + if updated.EditedAt == nil { + t.Error("returned row EditedAt should be set after edit") + } msg, _ := database.GetMessage(context.Background(), id) if msg.Content != "updated" { @@ -232,7 +240,7 @@ func TestEditMessage_NonOwnerCannotEdit(t *testing.T) { id, _ := database.CreateMessage(context.Background(), chID, ownerID, "original", nil) - err := database.EditMessage(context.Background(), id, otherID, "hacked") + _, err := database.EditMessage(context.Background(), id, otherID, "hacked") if err == nil { t.Error("EditMessage by non-owner should return error") } @@ -242,7 +250,7 @@ func TestEditMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "kim") - err := database.EditMessage(context.Background(), 9999, userID, "x") + _, err := database.EditMessage(context.Background(), 9999, userID, "x") if err == nil { t.Error("EditMessage non-existent should return error") } @@ -504,6 +512,49 @@ func TestSearchMessages_DeletedNotReturned(t *testing.T) { } } +// Migration 019 narrows the messages_au trigger to AFTER UPDATE OF content; +// an edit must still reindex the FTS table (old term gone, new term found). +func TestSearchMessages_EditReindexes(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "edith") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(context.Background(), chID, userID, "obsolete wording here", nil) + if _, err := database.EditMessage(context.Background(), id, userID, "fresh wording here"); err != nil { + t.Fatalf("EditMessage: %v", err) + } + + stale, _ := database.SearchMessages(context.Background(), "obsolete", nil, 10) + if len(stale) != 0 { + t.Errorf("expected 0 results for pre-edit content, got %d", len(stale)) + } + updated, _ := database.SearchMessages(context.Background(), "fresh", nil, 10) + if len(updated) != 1 { + t.Errorf("expected 1 result for post-edit content, got %d", len(updated)) + } +} + +// Pinning updates a non-content column, which the narrowed trigger must +// ignore — the message stays searchable and the FTS index stays consistent. +func TestSearchMessages_PinnedMessageStaysSearchable(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "pinny") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(context.Background(), chID, userID, "pinworthy announcement", nil) + if err := database.SetMessagePinned(context.Background(), id, true); err != nil { + t.Fatalf("SetMessagePinned: %v", err) + } + + results, err := database.SearchMessages(context.Background(), "pinworthy", nil, 10) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(results) != 1 { + t.Errorf("expected pinned message to remain searchable, got %d results", len(results)) + } +} + // ─── UpdateReadState ────────────────────────────────────────────────────────── func TestUpdateReadState_Upsert(t *testing.T) { @@ -672,6 +723,85 @@ func TestGetChannelUnreadCounts_WithUnreadMessages(t *testing.T) { } } +// A channel with no messages must still yield a 0,0 entry — the correlated +// subquery rewrite must not silently drop empty channels from the ready +// payload's unread map. +func TestGetChannelUnreadCounts_ZeroMessageChannelYieldsZeros(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "unreadzero") + chID := seedChannel(t, database, "emptychan") + + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + cu, ok := counts[chID] + if !ok { + t.Fatalf("empty channel %d missing from unread counts", chID) + } + if cu.LastMessageID != 0 || cu.UnreadCount != 0 { + t.Errorf("empty channel = {last:%d unread:%d}, want {0 0}", cu.LastMessageID, cu.UnreadCount) + } +} + +func TestGetChannelUnreadCounts_IncludesAnnouncementChannels(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "unreadann") + annID, err := database.CreateChannel(context.Background(), "announcements", "announcement", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel(announcement): %v", err) + } + voiceID, err := database.CreateChannel(context.Background(), "voicechan", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel(voice): %v", err) + } + + msgID, _ := database.CreateMessage(context.Background(), annID, userID, "server news", nil) + + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + cu, ok := counts[annID] + if !ok { + t.Fatalf("announcement channel %d missing from unread counts", annID) + } + if cu.LastMessageID != msgID { + t.Errorf("LastMessageID = %d, want %d", cu.LastMessageID, msgID) + } + if cu.UnreadCount != 1 { + t.Errorf("UnreadCount = %d, want 1", cu.UnreadCount) + } + if _, ok := counts[voiceID]; ok { + t.Errorf("voice channel %d must not appear in unread counts", voiceID) + } +} + +// Deleted messages count neither toward unread nor toward last_msg_id. +func TestGetChannelUnreadCounts_ExcludesDeleted(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "unreaddel") + chID := seedChannel(t, database, "unreaddelchan") + + keepID, _ := database.CreateMessage(context.Background(), chID, userID, "keep", nil) + delID, _ := database.CreateMessage(context.Background(), chID, userID, "gone", nil) + if err := database.DeleteMessage(context.Background(), delID, userID, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + cu := counts[chID] + if cu.LastMessageID != keepID { + t.Errorf("LastMessageID = %d, want %d (deleted excluded)", cu.LastMessageID, keepID) + } + if cu.UnreadCount != 1 { + t.Errorf("UnreadCount = %d, want 1 (deleted excluded)", cu.UnreadCount) + } +} + // ─── GetLatestMessageID ───────────────────────────────────────────────────── func TestGetLatestMessageID_Empty(t *testing.T) { diff --git a/Server/db/migrate.go b/Server/db/migrate.go index d8fcd161..37eebd9a 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -6,6 +6,10 @@ package db // schema_versions table records every applied migration filename and the UTC // timestamp at which it was applied. // +// Every statement here — including the schema_versions bookkeeping reads — +// runs on the writer pool so migration DDL and its tracking records are +// applied and observed on the single write connection. +// // Seeding for existing databases // -------------------------------- // When the server is first upgraded to include migration tracking, existing @@ -33,7 +37,7 @@ CREATE TABLE IF NOT EXISTS schema_versions ( // ensureSchemaVersions creates the tracking table if it does not yet exist. func ensureSchemaVersions(d *DB) error { - if _, err := d.sqlDB.Exec(createSchemaVersions); err != nil { + if _, err := d.writer.Exec(createSchemaVersions); err != nil { return fmt.Errorf("creating schema_versions: %w", err) } return nil @@ -43,7 +47,7 @@ func ensureSchemaVersions(d *DB) error { // without tracking — detected by the presence of the "users" table. func isExistingDatabase(d *DB) (bool, error) { var name string - err := d.sqlDB.QueryRow( + err := d.writer.QueryRow( "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", ).Scan(&name) if err != nil { @@ -58,7 +62,7 @@ func isExistingDatabase(d *DB) (bool, error) { // schemaVersionsExists reports whether the schema_versions table is present. func schemaVersionsExists(d *DB) (bool, error) { var name string - err := d.sqlDB.QueryRow( + err := d.writer.QueryRow( "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'", ).Scan(&name) if err != nil { @@ -73,7 +77,7 @@ func schemaVersionsExists(d *DB) (bool, error) { // isApplied reports whether a migration filename has already been recorded. func isApplied(d *DB, filename string) (bool, error) { var v string - err := d.sqlDB.QueryRow( + err := d.writer.QueryRow( "SELECT version FROM schema_versions WHERE version = ?", filename, ).Scan(&v) if err != nil { @@ -109,7 +113,7 @@ func sqlFilenames(fsys fs.FS) ([]string, error) { // without executing them. This is called once when upgrading a pre-tracking // database. func seedExistingDatabase(d *DB, filenames []string) error { - tx, err := d.sqlDB.Begin() + tx, err := d.writer.Begin() if err != nil { return fmt.Errorf("begin seed tx: %w", err) } @@ -197,7 +201,7 @@ func MigrateFS(database *DB, fsys fs.FS) error { func applyMigration(database *DB, name, rawSQL string) error { stmts := splitStatements(rawSQL) - tx, txErr := database.sqlDB.Begin() + tx, txErr := database.writer.Begin() if txErr != nil { return fmt.Errorf("begin tx for %s: %w", name, txErr) } diff --git a/Server/db/persisted_event.go b/Server/db/persisted_event.go index 85cf9eb5..0a276eb0 100644 --- a/Server/db/persisted_event.go +++ b/Server/db/persisted_event.go @@ -17,11 +17,13 @@ type PersistedEvent struct { } // PluginRow represents a row in the plugins table (Phase C Step 9). +// The JSON tags are part of the admin plugin API surface (GET +// /api/v1/admin/plugins), which the admin panel renders. type PluginRow struct { - ID int64 - Name string - Version string - Enabled bool - ManifestJSON string - InstalledAt time.Time + ID int64 `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + ManifestJSON string `json:"manifest_json"` + InstalledAt time.Time `json:"installed_at"` } diff --git a/Server/db/plugin_queries.go b/Server/db/plugin_queries.go index bb61a0be..0fbea4b5 100644 --- a/Server/db/plugin_queries.go +++ b/Server/db/plugin_queries.go @@ -11,7 +11,7 @@ import ( // unchanged. func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { - res, err := d.sqlDB.ExecContext(ctx, + res, err := d.writer.ExecContext(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`, name, version, manifestJSON, @@ -22,7 +22,7 @@ func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON stri id, err := res.LastInsertId() if err != nil || id == 0 { // On conflict path LastInsertId may be 0; look up by name. - row := d.sqlDB.QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, 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) } @@ -31,22 +31,22 @@ func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON stri } func (d *DB) EnablePlugin(ctx context.Context, id int64) error { - _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id) + _, err := d.writer.ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id) return err } func (d *DB) DisablePlugin(ctx context.Context, id int64) error { - _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id) + _, err := d.writer.ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id) return err } func (d *DB) UninstallPlugin(ctx context.Context, id int64) error { - _, err := d.sqlDB.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id) + _, err := d.writer.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id) return err } func (d *DB) GetPlugin(ctx context.Context, id int64) (*PluginRow, error) { - row := d.sqlDB.QueryRowContext(ctx, + row := d.reader.QueryRowContext(ctx, `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`, id, ) @@ -54,7 +54,7 @@ func (d *DB) GetPlugin(ctx context.Context, id int64) (*PluginRow, error) { } func (d *DB) GetPluginByName(ctx context.Context, name string) (*PluginRow, error) { - row := d.sqlDB.QueryRowContext(ctx, + row := d.reader.QueryRowContext(ctx, `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`, name, ) @@ -62,7 +62,7 @@ func (d *DB) GetPluginByName(ctx context.Context, name string) (*PluginRow, erro } func (d *DB) ListPlugins(ctx context.Context) ([]PluginRow, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`, ) if err != nil { @@ -85,7 +85,7 @@ func (d *DB) ListPlugins(ctx context.Context) ([]PluginRow, error) { } func (d *DB) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { - row := d.sqlDB.QueryRowContext(ctx, + row := d.reader.QueryRowContext(ctx, `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`, pluginID, key, ) @@ -97,7 +97,7 @@ func (d *DB) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byt } func (d *DB) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { - _, err := d.sqlDB.ExecContext(ctx, + _, err := d.writer.ExecContext(ctx, `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`, pluginID, key, value, @@ -106,7 +106,7 @@ func (d *DB) PluginKVSet(ctx context.Context, pluginID int64, key string, value } func (d *DB) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { - _, err := d.sqlDB.ExecContext(ctx, + _, err := d.writer.ExecContext(ctx, `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`, pluginID, key, ) @@ -114,7 +114,7 @@ func (d *DB) PluginKVDelete(ctx context.Context, pluginID int64, key string) err } func (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { - rows, err := d.sqlDB.QueryContext(ctx, + rows, err := d.reader.QueryContext(ctx, `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`, pluginID, prefix+"%", limit, ) diff --git a/Server/db/pool_test.go b/Server/db/pool_test.go new file mode 100644 index 00000000..6c45a645 --- /dev/null +++ b/Server/db/pool_test.go @@ -0,0 +1,263 @@ +package db_test + +// pool_test.go — file-backed reader/writer pool split tests. +// +// db.Open gives file-backed databases two pools: a single-connection writer +// and a multi-connection reader (see db.go). The per-connection PRAGMAs move +// into the DSN in that mode, because an Exec'd PRAGMA would only configure +// one arbitrary pooled connection. These tests pin the properties that split +// must preserve: foreign_keys=ON on every reader connection, WAL journaling, +// FK enforcement on the write path, and reads proceeding while a write +// transaction is open. + +import ( + "context" + "fmt" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/owncord/server/db" +) + +// openFileDB opens a temp-file-backed database with the full embedded +// migration set applied. +func openFileDB(t *testing.T) *db.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "pool_test.db") + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open(%q) error: %v", dbPath, err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + return database +} + +// seedChannelAndUser creates one text channel and one member user for +// message-write tests, returning their IDs. +func seedChannelAndUser(t *testing.T, database *db.DB) (channelID, userID int64) { + t.Helper() + ctx := context.Background() + channelID, err := database.AdminCreateChannel(ctx, "pool-test", "text", "", "", 99) + if err != nil { + t.Fatalf("AdminCreateChannel: %v", err) + } + userID, err = database.CreateUser(ctx, "pooluser", "x", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + return channelID, userID +} + +// TestFilePool_ForeignKeysOnReaderConnections asserts PRAGMA foreign_keys +// returns 1 on many reader-pool connections. The PRAGMA read routes to the +// reader pool, and the sequential + parallel mix below forces the pool to +// grow and to serve the checks from different physical connections — the +// regression this catches is the DSN `_pragma=` parameters being dropped, +// which would leave fresh pooled connections with foreign_keys=OFF. +func TestFilePool_ForeignKeysOnReaderConnections(t *testing.T) { + database := openFileDB(t) + ctx := context.Background() + + checkFK := func() error { + var fk int + if err := database.QueryRowContext(ctx, "PRAGMA foreign_keys;").Scan(&fk); err != nil { + return fmt.Errorf("PRAGMA foreign_keys: %w", err) + } + if fk != 1 { + return fmt.Errorf("foreign_keys = %d, want 1", fk) + } + return nil + } + + // Sequential warm-up checks. + for i := range 20 { + if err := checkFK(); err != nil { + t.Fatalf("sequential check %d: %v", i, err) + } + } + + // Parallel: 16 goroutines interleaving reads and PRAGMA checks so the + // pool opens multiple connections and the checks land on different ones. + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + for range 25 { + var n int + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&n); err != nil { + t.Errorf("read query: %v", err) + return + } + if err := checkFK(); err != nil { + t.Errorf("parallel check: %v", err) + return + } + } + }) + } + wg.Wait() + + // journal_mode must be WAL on the reader connections too. + var mode string + if err := database.QueryRowContext(ctx, "PRAGMA journal_mode;").Scan(&mode); err != nil { + t.Fatalf("PRAGMA journal_mode: %v", err) + } + if mode != "wal" { + t.Errorf("journal_mode = %q, want %q", mode, "wal") + } +} + +// TestFilePool_ConcurrentReadsAndWrites hammers the split with 8 writer +// goroutines (both the sqlc INSERT...RETURNING path, which travels through +// QueryRowContext and must be routed to the writer, and the raw ExecContext +// path) against 8 reader goroutines, then asserts nothing errored and every +// row landed. +func TestFilePool_ConcurrentReadsAndWrites(t *testing.T) { + database := openFileDB(t) + ctx := context.Background() + channelID, userID := seedChannelAndUser(t, database) + + const ( + writers = 8 + readers = 8 + perWriter = 25 + wantRows int64 = writers * perWriter + ) + + var wg sync.WaitGroup + + // Writers: CreateMessage exercises INSERT ... RETURNING via the dbtx + // router; PersistEvent exercises the plain ExecContext write path. + for w := range writers { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := range perWriter { + if _, err := database.CreateMessage(ctx, channelID, userID, fmt.Sprintf("msg %d-%d", w, i), nil); err != nil { + t.Errorf("CreateMessage: %v", err) + return + } + seq := int64(w*perWriter + i + 1) + if err := database.PersistEvent(ctx, seq, "test_event", channelID, []byte(`{}`)); err != nil { + t.Errorf("PersistEvent: %v", err) + return + } + } + }(w) + } + + // Readers: list messages and events while the writers run. + for range readers { + wg.Go(func() { + for range perWriter { + if _, err := database.GetMessages(ctx, channelID, 0, 50); err != nil { + t.Errorf("GetMessages: %v", err) + return + } + if _, err := database.GetEventsSince(ctx, 0, 50); err != nil { + t.Errorf("GetEventsSince: %v", err) + return + } + } + }) + } + wg.Wait() + + var msgCount, evtCount int64 + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&msgCount); err != nil { + t.Fatalf("count messages: %v", err) + } + if msgCount != wantRows { + t.Errorf("messages = %d, want %d", msgCount, wantRows) + } + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM events").Scan(&evtCount); err != nil { + t.Fatalf("count events: %v", err) + } + if evtCount != wantRows { + t.Errorf("events = %d, want %d", evtCount, wantRows) + } +} + +// TestFilePool_FKViolationRejectedOnFile proves foreign key enforcement is +// live on the writer connection of a file-backed database, through both the +// sqlc RETURNING write path and a raw ExecContext insert. +func TestFilePool_FKViolationRejectedOnFile(t *testing.T) { + database := openFileDB(t) + ctx := context.Background() + channelID, userID := seedChannelAndUser(t, database) + + // Nonexistent channel via the sqlc INSERT ... RETURNING path. + if _, err := database.CreateMessage(ctx, 999999, userID, "orphan", nil); err == nil { + t.Error("CreateMessage with nonexistent channel succeeded, want FK violation") + } + + // Nonexistent user via the raw ExecContext path. + if _, err := database.ExecContext(ctx, + `INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, 'orphan')`, + channelID, int64(999999), + ); err == nil { + t.Error("raw insert with nonexistent user succeeded, want FK violation") + } + + // The valid combination still works. + if _, err := database.CreateMessage(ctx, channelID, userID, "valid", nil); err != nil { + t.Errorf("CreateMessage with valid FKs: %v", err) + } +} + +// TestFilePool_ReadDuringOpenWriteTx verifies the WAL property the split +// exists for: a read on the reader pool completes while the writer holds an +// open (BEGIN IMMEDIATE) write transaction, seeing the pre-transaction +// snapshot, and sees the new data once the transaction commits. +func TestFilePool_ReadDuringOpenWriteTx(t *testing.T) { + database := openFileDB(t) + ctx := context.Background() + channelID, userID := seedChannelAndUser(t, database) + + if _, err := database.CreateMessage(ctx, channelID, userID, "before", nil); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, 'uncommitted')`, + channelID, userID, + ); err != nil { + _ = tx.Rollback() + t.Fatalf("tx insert: %v", err) + } + + // The read must not block on the open write transaction. Bound it with a + // timeout so a lock conflict fails fast instead of hanging the test. + readCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var count int64 + if err := database.QueryRowContext(readCtx, + "SELECT COUNT(*) FROM messages WHERE channel_id = ?", channelID, + ).Scan(&count); err != nil { + _ = tx.Rollback() + t.Fatalf("read during open write tx: %v", err) + } + if count != 1 { + t.Errorf("read during open tx saw %d messages, want 1 (pre-tx snapshot)", count) + } + + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if err := database.QueryRowContext(ctx, + "SELECT COUNT(*) FROM messages WHERE channel_id = ?", channelID, + ).Scan(&count); err != nil { + t.Fatalf("read after commit: %v", err) + } + if count != 2 { + t.Errorf("read after commit saw %d messages, want 2", count) + } +} diff --git a/Server/db/queries/sqlite/dm.sql b/Server/db/queries/sqlite/dm.sql index 4385c44e..752c56e3 100644 --- a/Server/db/queries/sqlite/dm.sql +++ b/Server/db/queries/sqlite/dm.sql @@ -10,6 +10,9 @@ SELECT user_id FROM dm_participants WHERE user_id = ? AND channel_id = ?; -- name: GetDMParticipantIDs :many SELECT user_id FROM dm_participants WHERE channel_id = ?; +-- name: GetUserDMChannelIDs :many +SELECT channel_id FROM dm_open_state WHERE user_id = ?; + -- name: GetUserDMChannels :many SELECT c.id AS channel_id, @@ -20,8 +23,11 @@ SELECT lm.id AS last_message_id, COALESCE(lm.content, '') AS last_message, COALESCE(lm.timestamp, '') AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = 0 THEN 1 END) AS unread_count + (SELECT COUNT(*) FROM messages mu + WHERE mu.channel_id = c.id AND mu.deleted = 0 + AND mu.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = dos.user_id), 0) + ) AS unread_count FROM dm_open_state dos JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? @@ -29,8 +35,5 @@ JOIN users u ON u.id = dp.user_id LEFT JOIN messages lm ON lm.id = ( SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 ) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? WHERE dos.user_id = ? -GROUP BY c.id ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC; diff --git a/Server/db/queries/sqlite/messages.sql b/Server/db/queries/sqlite/messages.sql index fdf8f2c7..f0ca8187 100644 --- a/Server/db/queries/sqlite/messages.sql +++ b/Server/db/queries/sqlite/messages.sql @@ -1,5 +1,6 @@ --- name: CreateMessage :execresult -INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?); +-- name: CreateMessage :one +INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?) +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp; -- name: GetMessage :one SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp @@ -12,8 +13,9 @@ FROM messages m JOIN users u ON m.user_id = u.id WHERE m.channel_id = ? AND m.deleted = 0 ORDER BY m.id DESC LIMIT ?; --- name: EditMessageContent :exec -UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?; +-- name: EditMessageContent :one +UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ? +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp; -- name: SoftDeleteMessage :exec UPDATE messages SET deleted = 1 WHERE id = ?; @@ -31,13 +33,14 @@ ON CONFLICT(user_id, channel_id) DO UPDATE SET last_message_id = excluded.last_m -- name: GetChannelUnreadCounts :many SELECT c.id, - COALESCE(MAX(m.id), 0) AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread + (SELECT COALESCE(MAX(m.id), 0) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0) AS last_msg_id, + (SELECT COUNT(*) FROM messages m + WHERE m.channel_id = c.id AND m.deleted = 0 + AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0 -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? -WHERE c.type = 'text' -GROUP BY c.id; +WHERE c.type IN ('text', 'announcement'); -- SearchMessages and SearchMessagesInChannel use the messages_fts FTS5 virtual -- table which sqlc cannot introspect. Those queries remain as hand-written Go diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go index 3b05637f..4d500903 100644 --- a/Server/db/role_queries.go +++ b/Server/db/role_queries.go @@ -66,7 +66,7 @@ func (d *DB) GetRoleForUser(ctx context.Context, userID int64) (*Role, error) { // GetUserWithRole returns the user and their role in a single query. // Returns (nil, nil, nil) when the user is not found. func (d *DB) GetUserWithRole(ctx context.Context, userID int64) (*User, *Role, error) { - row := d.sqlDB.QueryRowContext(ctx, + row := d.reader.QueryRowContext(ctx, `SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret, u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 775a8efa..2d5aa46f 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -257,7 +257,7 @@ func (d *DB) UpdateVoiceScreenshare(ctx context.Context, userID int64, screensha // voice channel. func (d *DB) CountChannelVoiceUsers(ctx context.Context, channelID int64) (int, error) { var count int - err := d.sqlDB.QueryRowContext(ctx, + err := d.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, channelID, ).Scan(&count) diff --git a/Server/go.mod b/Server/go.mod index c048411c..77aa7180 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -6,6 +6,7 @@ require ( aead.dev/minisign v0.3.0 github.com/BurntSushi/toml v1.6.0 github.com/coder/websocket v1.8.15 + github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 github.com/corazawaf/coraza/v3 v3.6.0 github.com/go-chi/chi/v5 v5.3.1 github.com/google/uuid v1.6.0 diff --git a/Server/go.sum b/Server/go.sum index 6da82d95..83fab9c2 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -40,6 +40,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM= +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY= github.com/corazawaf/coraza/v3 v3.6.0 h1:rfsGl6eRBzzUAyADFcpuO7qXLt0DZtYWhfTIuhcyAjQ= github.com/corazawaf/coraza/v3 v3.6.0/go.mod h1:q7gszZCSufoHIy9jV2NCgk+glYwZpP2mIKgbu2dZkvE= github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= diff --git a/Server/main.go b/Server/main.go index 8d7984ad..d59f979e 100644 --- a/Server/main.go +++ b/Server/main.go @@ -41,14 +41,17 @@ func main() { } // Create ring buffer for admin log viewer, then build a multi-handler - // that tees log records to both stdout (INFO+) and the ring buffer (DEBUG+). + // that tees log records to both stdout and the ring buffer. logBuf := admin.NewRingBuffer(2000) - // levelVar controls the stdout handler's threshold. It starts at INFO (the + // levelVar controls both handlers' thresholds. It starts at INFO (the // zero value) so early-startup logs are captured, then run() raises/lowers - // it once config.yaml / OWNCORD_LOGGING_LEVEL is loaded. + // it once config.yaml / OWNCORD_LOGGING_LEVEL is loaded. The ring buffer + // shares it rather than hard-wiring DEBUG: with both sinks gated, Enabled + // returns false for suppressed levels and every gated Debug call across + // the server becomes a no-op instead of formatting a ring entry. levelVar := new(slog.LevelVar) stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: levelVar}) - multiHandler := admin.NewMultiHandler(stdoutHandler, logBuf, slog.LevelDebug) + multiHandler := admin.NewMultiHandler(stdoutHandler, logBuf, levelVar) // logctx enriches records logged with a request/trace context (the // ...Context slog variants) with req_id and, under -tags otel, trace_id. log := slog.New(logctx.New(multiHandler)) @@ -86,13 +89,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer, levelVar *slog.LevelVar) er } // ── 1. Load configuration ────────────────────────────────────────────── - cfg, err := config.Load("config.yaml") + cfg, err := config.Load(config.DefaultPath) if err != nil { return fmt.Errorf("loading config: %w", err) } - // Apply the configured stdout log level. The ring buffer keeps capturing - // DEBUG regardless, so the admin panel's live log view is unaffected. + // Apply the configured log level. The admin panel's live log view (ring + // buffer) follows the same threshold — set logging.level to "debug" to + // capture debug records there. if lvl, ok := config.ParseLevel(cfg.Logging.Level); ok { levelVar.Set(lvl) } else { @@ -228,6 +232,22 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer, levelVar *slog.LevelVar) er }() } + // ── 5d. Async audit writer ───────────────────────────────────────────── + // Moves audit-log INSERTs off the request path: once the writer is + // installed, WriteAudit enqueues here and a background goroutine batches + // the writes (same shape as the event persister above). Paths that never + // install a writer — the token CLI, tests — keep the synchronous + // behavior. This defer is registered after `defer database.Close()` so + // LIFO ordering drains the queue before the database is torn down. + auditWriter := db.NewAuditWriter(database, 1024, 50, 100*time.Millisecond) + auditWriter.Start(bgCtx) + database.SetAuditWriter(auditWriter) + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + auditWriter.Stop(stopCtx) + }() + // ── 6. Start server ──────────────────────────────────────────────────── addr := fmt.Sprintf(":%d", cfg.Server.Port) srv := &http.Server{ diff --git a/Server/migrations/019_perf_indexes.sql b/Server/migrations/019_perf_indexes.sql new file mode 100644 index 00000000..e19ba8ea --- /dev/null +++ b/Server/migrations/019_perf_indexes.sql @@ -0,0 +1,29 @@ +-- Migration 019: performance indexes + FTS trigger scope fix. + +-- Attachments are batch-fetched per message page. Without an index on +-- message_id every page load scans the whole attachments table. +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); + +-- GetAllChannelPermissionsForRole looks up overrides by role_id, which the +-- (channel_id, role_id) UNIQUE auto-index cannot serve. The covering index +-- includes allow/deny so the lookup never touches the table. The old +-- idx_channel_overrides_channel_role (migration 006) exactly duplicated the +-- UNIQUE auto-index, so it only cost write time — drop it. +CREATE INDEX IF NOT EXISTS idx_channel_overrides_role + ON channel_overrides(role_id, channel_id, allow, deny); +DROP INDEX IF EXISTS idx_channel_overrides_channel_role; + +-- Pinned-message listing seeks this partial index instead of scanning the +-- channel's whole history. +CREATE INDEX IF NOT EXISTS idx_messages_pinned + ON messages(channel_id, id DESC) WHERE pinned = 1 AND deleted = 0; + +-- The original messages_au trigger fired on EVERY update, so pinning or +-- soft-deleting a message paid for a full FTS delete+reinsert even though the +-- content was unchanged. Scope it to content updates only — the single case +-- that actually needs a reindex (message edits). +DROP TRIGGER IF EXISTS messages_au; +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; diff --git a/Server/migrations/020_drop_redundant_indexes.sql b/Server/migrations/020_drop_redundant_indexes.sql new file mode 100644 index 00000000..8c4b6518 --- /dev/null +++ b/Server/migrations/020_drop_redundant_indexes.sql @@ -0,0 +1,7 @@ +-- Drop indexes that exactly duplicate the auto-indexes SQLite creates for +-- UNIQUE constraints. sessions.token and invites.code are both declared +-- UNIQUE in 001_initial_schema.sql, so these secondary indexes provide no +-- read benefit and cost an extra index update on every session insert and +-- invite create. +DROP INDEX IF EXISTS idx_sessions_token; +DROP INDEX IF EXISTS idx_invites_code; diff --git a/Server/service/channel.go b/Server/service/channel.go index f9a669f6..b1a963c4 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/telemetry" @@ -105,7 +106,7 @@ func (s *ChannelService) HandleTyping(ctx context.Context, userID, channelID int } // Per-user-per-channel rate limit. - ratKey := fmt.Sprintf("typing:%d:%d", userID, channelID) + ratKey := auth.Key(auth.Key("typing", userID), channelID) if limiter != nil && !limiter.Allow(ratKey, 1, 3*time.Second) { return nil, nil } @@ -145,7 +146,7 @@ func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, }, ) error { // Rate limit. - ratKey := fmt.Sprintf("presence:%d", userID) + ratKey := auth.Key("presence", userID) if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) { return ErrRateLimited } diff --git a/Server/service/datastore.go b/Server/service/datastore.go index f6eb5ca5..747b9374 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -19,10 +19,11 @@ import ( type Store interface { // ── Messages / reactions / read-state ── CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) + CreateMessageReturning(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (*db.Message, error) GetMessage(ctx context.Context, id int64) (*db.Message, error) GetMessages(ctx context.Context, channelID, before int64, limit int) ([]db.MessageWithUser, error) GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) - EditMessage(ctx context.Context, id, userID int64, content string) error + EditMessage(ctx context.Context, id, userID int64, content string) (*db.Message, error) DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) @@ -112,6 +113,7 @@ type Store interface { // ── Direct messages ── GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*db.Channel, bool, error) GetUserDMChannels(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) + GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) OpenDM(ctx context.Context, userID, channelID int64) error CloseDM(ctx context.Context, userID, channelID int64) error IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) diff --git a/Server/service/message.go b/Server/service/message.go index 52f4990d..999a7b7b 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -1,18 +1,13 @@ package service import ( - "context" "errors" "fmt" - "log/slog" - "time" "unicode/utf8" "github.com/microcosm-cc/bluemonday" "github.com/owncord/server/auth" "github.com/owncord/server/db" - "github.com/owncord/server/permissions" - "github.com/owncord/server/telemetry" ) // sanitizer is the shared HTML sanitization policy (strips all tags). @@ -113,672 +108,6 @@ func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLim } } -// SendMessage validates, persists, and prepares broadcast data for a new message. -// Callers are responsible for emitting the appropriate events. -func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) (*SendMessageResult, error) { - // Phase B Step 8 — wrap the public service entrypoint in a tracing span - // and a duration histogram. Both are no-ops in the default build. - ctx, span := telemetry.GlobalTracer("service/message").Start(ctx, "MessageService.SendMessage", - telemetry.Int64("user_id", p.UserID), - telemetry.Int64("channel_id", p.ChannelID), - ) - start := time.Now() - defer func() { - telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, - telemetry.String("method", "SendMessage")) - span.End() - }() - - // Rate limit. - ratKey := fmt.Sprintf("chat:%d", p.UserID) - if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { - return nil, ErrRateLimited - } - - if p.ChannelID <= 0 { - return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) - } - - ch, err := s.st.GetChannel(ctx, p.ChannelID) - if err != nil || ch == nil { - return nil, fmt.Errorf("%w: channel not found", ErrNotFound) - } - - isDM := ch.Type == "dm" - - // Permission check. - if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil { - return nil, err - } - - // Slow mode (non-DM only). - if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) { - slowKey := fmt.Sprintf("slow:%d:%d", p.UserID, p.ChannelID) - if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { - return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode) - } - } - - // Validate and sanitize content. - content, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0) - if err != nil { - return nil, err - } - - // Attachment permission (non-DM). - if !isDM && len(p.AttachmentIDs) > 0 { - if !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) { - return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden) - } - } - - // Persist message. - msgID, err := s.st.CreateMessage(ctx, p.ChannelID, p.UserID, content, p.ReplyTo) - if err != nil { - slog.Error("MessageService.SendMessage CreateMessage", "err", err) - return nil, fmt.Errorf("%w: failed to save message", ErrInternal) - } - - // Link attachments. Ownership is enforced atomically inside the link - // UPDATE itself (uploader match + still unlinked), so another user's - // upload, an already-linked attachment, or a nonexistent id is skipped by - // the statement — no check-then-link race and no N+1 pre-verification. - var attachments []db.AttachmentInfo - if len(p.AttachmentIDs) > 0 { - linked, linkErr := s.st.LinkAttachmentsToMessage(ctx, msgID, p.UserID, p.AttachmentIDs) - if linkErr != nil { - slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) - // Cleanup: soft-delete the message. The compensating delete must run - // even when the link failed because the request ctx was canceled. - if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil { - slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) - } - return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) - } - if linked < int64(len(p.AttachmentIDs)) { - 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 { - attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID}) - if attErr != nil { - slog.Error("MessageService.SendMessage GetAttachments", "err", attErr) - } else { - attachments = attMap[msgID] - } - } - } - - // Fetch message for timestamp. Post-commit: the message exists whether or - // not the sender is still connected, so the refetch that feeds the fan-out - // must not die with the sender's ctx. - msg, err := s.st.GetMessage(context.WithoutCancel(ctx), msgID) - if err != nil || msg == nil { - slog.Error("MessageService.SendMessage GetMessage after create", "err", err) - return nil, fmt.Errorf("%w: failed to retrieve message", ErrInternal) - } - - result := &SendMessageResult{ - MessageID: msgID, - Timestamp: msg.Timestamp, - Content: content, - IsDM: isDM, - Channel: ch, - Attachments: attachments, - } - - // DM path: open DM for recipients. - if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, p.ChannelID) - if pErr != nil { - slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID) - return result, nil // Message saved, skip DM side effects. - } - result.ParticipantIDs = participantIDs - - sender, _ := s.st.GetUserByID(ctx, p.UserID) - result.SenderUser = sender - - for _, pid := range participantIDs { - if pid == p.UserID { - continue - } - if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { - slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID) - continue - } - result.OpenedDMFor = append(result.OpenedDMFor, pid) - } - } - - slog.Debug("message sent", "user", p.Username, "channel_id", p.ChannelID, "msg_id", msgID) - return result, nil -} - -// EditMessage validates and persists a message edit. -func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, rawContent string) (*EditMessageResult, error) { - // Rate limit. - ratKey := fmt.Sprintf("chat_edit:%d", userID) - if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { - return nil, ErrRateLimited - } - - if msgID <= 0 { - return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) - } - - content, err := sanitizeContent(rawContent, false) - if err != nil { - return nil, err - } - - // Fetch message. - msg, err := s.st.GetMessage(ctx, msgID) - if err != nil || msg == nil { - return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) - } - if msg.Deleted { - return nil, fmt.Errorf("%w: cannot edit this message", ErrDeletedMessage) - } - - // Channel type for DM-aware permissions. - ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) - chanType := "" - if chErr == nil && ch != nil { - chanType = ch.Type - } - isDM := chanType == "dm" - - if isDM { - ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) - if dmErr != nil || !ok { - return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) - } - if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil { - return nil, blkErr - } - } else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil { - // An edit injects new text into the channel and is fanned out to every - // reader, so it must clear the same gate as a send rather than - // SEND_MESSAGES alone: READ_MESSAGES so a role locked out of a private - // channel (the panel's "Can access" toggle denies - // READ_MESSAGES|CONNECT_VOICE and leaves SEND_MESSAGES intact) cannot - // rewrite its old posts, and the announcement rule so a demoted - // moderator cannot rewrite a trusted broadcast. Mirrors DeleteMessage, - // SetMessagePinned and handleReaction, which already require - // READ_MESSAGES. The reason is collapsed into this sink's single opaque - // error so the reply stays an ownership/permission non-oracle. - return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) - } - - // EditMessage checks ownership internally. - if err := s.st.EditMessage(ctx, msgID, userID, content); err != nil { - return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) - } - - // Re-fetch for updated edited_at timestamp. Post-commit: must not die with - // the editor's ctx or the committed edit is never broadcast. - msg, err = s.st.GetMessage(context.WithoutCancel(ctx), msgID) - if err != nil || msg == nil { - slog.Error("MessageService.EditMessage GetMessage after edit", "err", err, "msg_id", msgID) - return nil, fmt.Errorf("%w: edit saved but broadcast failed", ErrInternal) - } - - editedAt := "" - if msg.EditedAt != nil { - editedAt = *msg.EditedAt - } - - result := &EditMessageResult{ - MessageID: msgID, - ChannelID: msg.ChannelID, - Content: content, - EditedAt: editedAt, - IsDM: isDM, - } - - if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) - if pErr != nil { - slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - } else { - result.ParticipantIDs = participantIDs - } - } - - slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID) - return result, nil -} - -// DeleteMessage validates and soft-deletes a message. -func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) { - // Rate limit. - ratKey := fmt.Sprintf("chat_delete:%d", userID) - if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { - return nil, ErrRateLimited - } - - if msgID <= 0 { - return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) - } - - msg, err := s.st.GetMessage(ctx, msgID) - if err != nil || msg == nil { - return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) - } - - ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) - isDM := chErr == nil && ch != nil && ch.Type == "dm" - - var isMod bool - if isDM { - ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) - if dmErr != nil || !ok { - return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) - } - } else { - // Require READ_MESSAGES alongside MANAGE_MESSAGES (and alongside - // SEND_MESSAGES on the author path) so a role explicitly denied access to - // a channel cannot delete messages in it. Mirrors handleReaction and - // checkSendPermission, which both require ReadMessages for non-DM channels. - isMsgOwner := msg.UserID == userID - canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages) - canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.SendMessages)) - if !canDelete { - return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) - } - // db.DeleteMessage skips the ownership check when ismod is true, so the - // moderation flag must reuse the decision made above rather than - // re-checking MANAGE_MESSAGES without READ_MESSAGES. - isMod = canManage - } - - if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { - return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) - } - - slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) - // Audit rows must survive a request canceled after the delete committed. - db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_delete", "message", msgID, - fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) - - result := &DeleteMessageResult{ - MessageID: msgID, - ChannelID: msg.ChannelID, - IsDM: isDM, - IsMod: isMod, - } - - if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) - if pErr != nil { - slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - } else { - result.ParticipantIDs = participantIDs - } - } - - return result, nil -} - -// AddReaction adds a reaction to a message. -func (s *MessageService) AddReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(ctx, userID, msgID, emoji, true) -} - -// RemoveReaction removes a reaction from a message. -func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(ctx, userID, msgID, emoji, false) -} - -func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { - // Rate limit. - ratKey := fmt.Sprintf("reaction:%d", userID) - if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) { - return nil, ErrRateLimited - } - - if msgID <= 0 { - return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) - } - if emoji == "" || len([]rune(emoji)) > 32 { - return nil, fmt.Errorf("%w: invalid emoji", ErrBadRequest) - } - // Reject control characters. - for _, r := range emoji { - if r <= 0x1F || r == 0x7F { - return nil, fmt.Errorf("%w: emoji contains control characters", ErrBadRequest) - } - } - // Sanitize check. - if sanitizer.Sanitize(emoji) != emoji { - return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) - } - - msg, err := s.st.GetMessage(ctx, msgID) - if err != nil || msg == nil { - return nil, fmt.Errorf("%w: message not found", ErrBadRequest) - } - if msg.Deleted { - return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest) - } - - ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) - isDM := chErr == nil && ch != nil && ch.Type == "dm" - - if isDM { - ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) - if dmErr != nil || !ok { - return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) - } - if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil { - return nil, blkErr - } - } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { - // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot - // react in a channel they cannot read. Mirrors checkSendPermission, - // which requires ReadMessages|SendMessages for non-DM sends. - return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) - } - - action := "add" - if add { - if err := s.st.AddReaction(ctx, msgID, userID, emoji); err != nil { - slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID) - return nil, fmt.Errorf("%w: reaction already exists", ErrConflict) - } - } else { - action = "remove" - if err := s.st.RemoveReaction(ctx, msgID, userID, emoji); err != nil { - slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID) - return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest) - } - } - - result := &ReactionResult{ - MessageID: msgID, - ChannelID: msg.ChannelID, - UserID: userID, - Emoji: emoji, - Action: action, - IsDM: isDM, - } - - if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) - if pErr != nil { - slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - } else { - result.ParticipantIDs = participantIDs - } - } - - return result, nil -} - -// GetMessages retrieves paginated messages for a channel with permission checks. -func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { - if channelID <= 0 { - return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) - } - - ch, err := s.st.GetChannel(ctx, channelID) - if err != nil || ch == nil { - return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound) - } - - // Permission check. - if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil || !ok { - return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) - } - } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { - return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) - } - - if limit <= 0 { - limit = 50 - } - if limit > 100 { - limit = 100 - } - - // Fetch one extra to detect has_more. - msgs, err := s.st.GetMessagesForAPI(ctx, channelID, before, limit+1, userID) - if err != nil { - slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID) - return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal) - } - - hasMore := len(msgs) > limit - if hasMore { - msgs = msgs[:limit] - } - - return msgs, hasMore, nil -} - -// SearchMessages performs full-text search across accessible channels. -func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { - if query == "" { - return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest) - } - if limit <= 0 { - limit = 50 - } - if limit > 100 { - limit = 100 - } - - // Single-channel search. - if channelID != nil && *channelID > 0 { - ch, err := s.st.GetChannel(ctx, *channelID) - if err != nil || ch == nil { - return nil, fmt.Errorf("%w: channel not found", ErrNotFound) - } - if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, *channelID) - if err != nil || !ok { - return nil, fmt.Errorf("%w: access denied", ErrForbidden) - } - } else if !s.perms.HasChannelPerm(ctx, userID, *channelID, permissions.ReadMessages) { - return nil, fmt.Errorf("%w: access denied", ErrForbidden) - } - results, err := s.st.SearchMessages(ctx, query, channelID, limit) - if err != nil { - return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) - } - return results, nil - } - - // Global search: build accessible channel list. - accessibleIDs, err := s.GetAccessibleChannelIDs(ctx, userID) - if err != nil { - return nil, err - } - if len(accessibleIDs) == 0 { - return nil, nil - } - - results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit) - if err != nil { - return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) - } - return results, nil -} - -// GetPinnedMessages retrieves pinned messages for a channel. -func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { - if channelID <= 0 { - return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) - } - ch, err := s.st.GetChannel(ctx, channelID) - if err != nil || ch == nil { - return nil, fmt.Errorf("%w: channel not found", ErrNotFound) - } - if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil || !ok { - return nil, fmt.Errorf("%w: access denied", ErrNotFound) - } - } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { - return nil, fmt.Errorf("%w: access denied", ErrForbidden) - } - msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) - if err != nil { - return nil, fmt.Errorf("%w: failed to fetch pinned messages: %v", ErrInternal, err) - } - return msgs, nil -} - -// SetMessagePinned pins or unpins a message. -func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID, msgID int64, pinned bool) error { - if channelID <= 0 || msgID <= 0 { - return fmt.Errorf("%w: invalid IDs", ErrBadRequest) - } - ch, err := s.st.GetChannel(ctx, channelID) - if err != nil || ch == nil { - return fmt.Errorf("%w: channel not found", ErrNotFound) - } - if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil || !ok { - return fmt.Errorf("%w: access denied", ErrNotFound) - } - if blkErr := requireDMNotBlocked(ctx, s.st, userID, channelID); blkErr != nil { - return blkErr - } - } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.ManageMessages) { - // Require READ_MESSAGES alongside MANAGE_MESSAGES so a role locked out - // of a private channel cannot mutate its pins — the admin panel's - // "Can access" toggle denies READ_MESSAGES|CONNECT_VOICE and leaves - // MANAGE_MESSAGES intact. Mirrors handleReaction and checkSendPermission. - return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden) - } - // Verify message belongs to this channel. - msg, err := s.st.GetMessage(ctx, msgID) - if err != nil || msg == nil || msg.ChannelID != channelID { - return fmt.Errorf("%w: message not found in this channel", ErrNotFound) - } - return s.st.SetMessagePinned(ctx, msgID, pinned) -} - -// GetAccessibleChannelIDs returns all channel IDs the user can read. -func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) { - channels, err := s.st.ListChannels(ctx) - if err != nil { - return nil, fmt.Errorf("%w: failed to list channels: %v", ErrInternal, err) - } - - role, err := s.perms.GetRoleForUser(ctx, userID) - if err != nil || role == nil { - return nil, fmt.Errorf("%w: failed to get role: %v", ErrInternal, err) - } - - var overrides map[int64]db.ChannelOverride - if !permissions.HasAdmin(role.Permissions) { - var overrideErr error - overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) - if overrideErr != nil { - return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr) - } - } - - // Single visibility predicate shared with REST ListVisibleChannels and the - // ws ready payload, so no site can drift. - visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) - var ids []int64 - for i := range channels { - if visibleIDs[channels[i].ID] { - ids = append(ids, channels[i].ID) - } - } - - // Also include DM channels the user participates in. - dmChannels, err := s.st.GetUserDMChannels(ctx, userID) - if err == nil { - for _, dmc := range dmChannels { - ids = append(ids, dmc.ChannelID) - } - } - - return ids, nil -} - -// CanPost reports whether userID may post into channelID, applying the same -// checks as a real message send: channel permissions via the cached checker -// for regular channels; participant membership AND block status for DMs. -// Exists so gates outside the send flow (the plugin broadcast path) share -// exactly this policy instead of hand-rolling a weaker copy. -func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error { - ch, err := s.st.GetChannel(ctx, channelID) - if err != nil || ch == nil { - return fmt.Errorf("%w: channel not found", ErrNotFound) - } - return s.checkSendPermission(ctx, userID, channelID, ch.Type) -} - -// checkSendPermission validates send permission for a channel of the given -// type. Announcement channels are readable by anyone with READ_MESSAGES but -// only postable by users with MANAGE_MESSAGES (posting is restricted to -// moderators/admins); all other non-DM channels require SEND_MESSAGES. -func (s *MessageService) checkSendPermission(ctx context.Context, userID, channelID int64, chanType string) error { - isDM := chanType == "dm" - if isDM { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil { - return fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err) - } - if !ok { - return fmt.Errorf("%w: not a participant in this DM", ErrForbidden) - } - return requireDMNotBlocked(ctx, s.st, userID, channelID) - } - if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.SendMessages) { - return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden) - } - // Announcement channels: posting is restricted to users who can manage - // messages, even though everyone with READ_MESSAGES can view them. - if chanType == "announcement" && !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { - return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden) - } - return nil -} - -// requireDMNotBlocked reports ErrBlocked when userID and the other participant -// 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 -// 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. -// -// Callers keep their own IsDMParticipant check. Its failure mode is -// deliberately different per sink (ErrForbidden for edit, ErrBadRequest for -// reactions, ErrNotFound for pins so a foreign DM's existence stays hidden) -// and flattening them here would change client-visible status codes. -// -// A GetDMRecipient lookup failure or a DM with no other participant is treated -// as "not blocked", carrying over the posture the send path has always had -// rather than newly failing closed on all five sinks at once. -func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error { - recipient, err := st.GetDMRecipient(ctx, channelID, userID) - if err != nil || recipient == nil { - return nil //nolint:nilerr // carries over checkSendPermission's posture: a lookup failure or a DM with no other participant is not a block - } - blocked, blkErr := st.IsEitherBlocked(ctx, userID, recipient.ID) - if blkErr != nil { - return fmt.Errorf("%w: failed to check block status: %v", ErrInternal, blkErr) - } - if blocked { - return fmt.Errorf("%w: user is blocked", ErrBlocked) - } - return nil -} - // sanitizeContent validates and sanitizes message content. func sanitizeContent(raw string, allowEmpty bool) (string, error) { if len(raw) > maxMessageLen*4 { diff --git a/Server/service/message_crud.go b/Server/service/message_crud.go new file mode 100644 index 00000000..11d3efff --- /dev/null +++ b/Server/service/message_crud.go @@ -0,0 +1,308 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/telemetry" +) + +// SendMessage validates, persists, and prepares broadcast data for a new message. +// Callers are responsible for emitting the appropriate events. +func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) (*SendMessageResult, error) { + // Phase B Step 8 — wrap the public service entrypoint in a tracing span + // and a duration histogram. Both are no-ops in the default build. + ctx, span := telemetry.GlobalTracer("service/message").Start(ctx, "MessageService.SendMessage", + telemetry.Int64("user_id", p.UserID), + telemetry.Int64("channel_id", p.ChannelID), + ) + start := time.Now() + defer func() { + telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, + telemetry.String("method", "SendMessage")) + span.End() + }() + + // Rate limit. + ratKey := auth.Key("chat", p.UserID) + if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { + return nil, ErrRateLimited + } + + if p.ChannelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) + } + + ch, err := s.st.GetChannel(ctx, p.ChannelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + + isDM := ch.Type == "dm" + + // Permission check. + if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil { + return nil, err + } + + // Slow mode (non-DM only). + if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) { + slowKey := auth.Key(auth.Key("slow", p.UserID), p.ChannelID) + if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { + return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode) + } + } + + // Validate and sanitize content. + content, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0) + if err != nil { + return nil, err + } + + // Attachment permission (non-DM). + if !isDM && len(p.AttachmentIDs) > 0 { + if !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) { + return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden) + } + } + + // Persist message. RETURNING hands back the inserted row, so the DB-assigned + // timestamp the fan-out needs arrives with the insert instead of a re-read. + msg, err := s.st.CreateMessageReturning(ctx, p.ChannelID, p.UserID, content, p.ReplyTo) + if err != nil { + slog.Error("MessageService.SendMessage CreateMessage", "err", err) + return nil, fmt.Errorf("%w: failed to save message", ErrInternal) + } + msgID := msg.ID + + // Link attachments. Ownership is enforced atomically inside the link + // UPDATE itself (uploader match + still unlinked), so another user's + // upload, an already-linked attachment, or a nonexistent id is skipped by + // the statement — no check-then-link race and no N+1 pre-verification. + var attachments []db.AttachmentInfo + if len(p.AttachmentIDs) > 0 { + linked, linkErr := s.st.LinkAttachmentsToMessage(ctx, msgID, p.UserID, p.AttachmentIDs) + if linkErr != nil { + slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) + // Cleanup: soft-delete the message. The compensating delete must run + // even when the link failed because the request ctx was canceled. + if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil { + slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) + } + return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) + } + if linked < int64(len(p.AttachmentIDs)) { + 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 { + attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID}) + if attErr != nil { + slog.Error("MessageService.SendMessage GetAttachments", "err", attErr) + } else { + attachments = attMap[msgID] + } + } + } + + result := &SendMessageResult{ + MessageID: msgID, + Timestamp: msg.Timestamp, + Content: content, + IsDM: isDM, + Channel: ch, + Attachments: attachments, + } + + // DM path: open DM for recipients. + if isDM { + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, p.ChannelID) + if pErr != nil { + slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID) + return result, nil // Message saved, skip DM side effects. + } + result.ParticipantIDs = participantIDs + + sender, _ := s.st.GetUserByID(ctx, p.UserID) + result.SenderUser = sender + + for _, pid := range participantIDs { + if pid == p.UserID { + continue + } + if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { + slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID) + continue + } + result.OpenedDMFor = append(result.OpenedDMFor, pid) + } + } + + slog.Debug("message sent", "user", p.Username, "channel_id", p.ChannelID, "msg_id", msgID) + return result, nil +} + +// EditMessage validates and persists a message edit. +func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, rawContent string) (*EditMessageResult, error) { + // Rate limit. + ratKey := auth.Key("chat_edit", userID) + if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { + return nil, ErrRateLimited + } + + if msgID <= 0 { + return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) + } + + content, err := sanitizeContent(rawContent, false) + if err != nil { + return nil, err + } + + // Fetch message. + msg, err := s.st.GetMessage(ctx, msgID) + if err != nil || msg == nil { + return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) + } + if msg.Deleted { + return nil, fmt.Errorf("%w: cannot edit this message", ErrDeletedMessage) + } + + // Channel type for DM-aware permissions. + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) + chanType := "" + if chErr == nil && ch != nil { + chanType = ch.Type + } + isDM := chanType == "dm" + + if isDM { + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) + if dmErr != nil || !ok { + return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) + } + if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil { + return nil, blkErr + } + } else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil { + // An edit injects new text into the channel and is fanned out to every + // reader, so it must clear the same gate as a send rather than + // SEND_MESSAGES alone: READ_MESSAGES so a role locked out of a private + // channel (the panel's "Can access" toggle denies + // READ_MESSAGES|CONNECT_VOICE and leaves SEND_MESSAGES intact) cannot + // rewrite its old posts, and the announcement rule so a demoted + // moderator cannot rewrite a trusted broadcast. Mirrors DeleteMessage, + // SetMessagePinned and handleReaction, which already require + // READ_MESSAGES. The reason is collapsed into this sink's single opaque + // error so the reply stays an ownership/permission non-oracle. + return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) + } + + // EditMessage checks ownership internally and returns the updated row via + // RETURNING, so the edited_at the broadcast needs arrives with the write. + msg, err = s.st.EditMessage(ctx, msgID, userID, content) + if err != nil || msg == nil { + return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) + } + + editedAt := "" + if msg.EditedAt != nil { + editedAt = *msg.EditedAt + } + + result := &EditMessageResult{ + MessageID: msgID, + ChannelID: msg.ChannelID, + Content: content, + EditedAt: editedAt, + IsDM: isDM, + } + + if isDM { + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) + if pErr != nil { + slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) + } else { + result.ParticipantIDs = participantIDs + } + } + + slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID) + return result, nil +} + +// DeleteMessage validates and soft-deletes a message. +func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) { + // Rate limit. + ratKey := auth.Key("chat_delete", userID) + if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { + return nil, ErrRateLimited + } + + if msgID <= 0 { + return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) + } + + msg, err := s.st.GetMessage(ctx, msgID) + if err != nil || msg == nil { + return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) + } + + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) + isDM := chErr == nil && ch != nil && ch.Type == "dm" + + var isMod bool + if isDM { + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) + if dmErr != nil || !ok { + return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) + } + } else { + // Require READ_MESSAGES alongside MANAGE_MESSAGES (and alongside + // SEND_MESSAGES on the author path) so a role explicitly denied access to + // a channel cannot delete messages in it. Mirrors handleReaction and + // checkSendPermission, which both require ReadMessages for non-DM channels. + isMsgOwner := msg.UserID == userID + canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages) + canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.SendMessages)) + if !canDelete { + return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) + } + // db.DeleteMessage skips the ownership check when ismod is true, so the + // moderation flag must reuse the decision made above rather than + // re-checking MANAGE_MESSAGES without READ_MESSAGES. + isMod = canManage + } + + if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { + return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) + } + + slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) + // Audit rows must survive a request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_delete", "message", msgID, + fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) + + result := &DeleteMessageResult{ + MessageID: msgID, + ChannelID: msg.ChannelID, + IsDM: isDM, + IsMod: isMod, + } + + if isDM { + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) + if pErr != nil { + slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) + } else { + result.ParticipantIDs = participantIDs + } + } + + return result, nil +} diff --git a/Server/service/message_perms.go b/Server/service/message_perms.go new file mode 100644 index 00000000..0ab830bb --- /dev/null +++ b/Server/service/message_perms.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "fmt" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// GetAccessibleChannelIDs returns all channel IDs the user can read. +func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) { + channels, err := s.st.ListChannels(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list channels: %v", ErrInternal, err) + } + + role, err := s.perms.GetRoleForUser(ctx, userID) + if err != nil || role == nil { + return nil, fmt.Errorf("%w: failed to get role: %v", ErrInternal, err) + } + + var overrides map[int64]db.ChannelOverride + if !permissions.HasAdmin(role.Permissions) { + var overrideErr error + overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) + if overrideErr != nil { + return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr) + } + } + + // Single visibility predicate shared with REST ListVisibleChannels and the + // ws ready payload, so no site can drift. + visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) + var ids []int64 + for i := range channels { + if visibleIDs[channels[i].ID] { + ids = append(ids, channels[i].ID) + } + } + + // Also include DM channels the user participates in. Only the IDs are + // needed here, so skip the full DM query's preview/unread work. + dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID) + if err == nil { + ids = append(ids, dmIDs...) + } + + return ids, nil +} + +// CanPost reports whether userID may post into channelID, applying the same +// checks as a real message send: channel permissions via the cached checker +// for regular channels; participant membership AND block status for DMs. +// Exists so gates outside the send flow (the plugin broadcast path) share +// exactly this policy instead of hand-rolling a weaker copy. +func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error { + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + return s.checkSendPermission(ctx, userID, channelID, ch.Type) +} + +// checkSendPermission validates send permission for a channel of the given +// type. Announcement channels are readable by anyone with READ_MESSAGES but +// only postable by users with MANAGE_MESSAGES (posting is restricted to +// moderators/admins); all other non-DM channels require SEND_MESSAGES. +func (s *MessageService) checkSendPermission(ctx context.Context, userID, channelID int64, chanType string) error { + isDM := chanType == "dm" + if isDM { + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil { + return fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err) + } + if !ok { + return fmt.Errorf("%w: not a participant in this DM", ErrForbidden) + } + return requireDMNotBlocked(ctx, s.st, userID, channelID) + } + if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.SendMessages) { + return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden) + } + // Announcement channels: posting is restricted to users who can manage + // messages, even though everyone with READ_MESSAGES can view them. + if chanType == "announcement" && !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { + return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden) + } + return nil +} + +// requireDMNotBlocked reports ErrBlocked when userID and the other participant +// 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 +// 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. +// +// Callers keep their own IsDMParticipant check. Its failure mode is +// deliberately different per sink (ErrForbidden for edit, ErrBadRequest for +// reactions, ErrNotFound for pins so a foreign DM's existence stays hidden) +// and flattening them here would change client-visible status codes. +// +// A GetDMRecipient lookup failure or a DM with no other participant is treated +// as "not blocked", carrying over the posture the send path has always had +// rather than newly failing closed on all five sinks at once. +func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error { + recipient, err := st.GetDMRecipient(ctx, channelID, userID) + if err != nil || recipient == nil { + return nil //nolint:nilerr // carries over checkSendPermission's posture: a lookup failure or a DM with no other participant is not a block + } + blocked, blkErr := st.IsEitherBlocked(ctx, userID, recipient.ID) + if blkErr != nil { + return fmt.Errorf("%w: failed to check block status: %v", ErrInternal, blkErr) + } + if blocked { + return fmt.Errorf("%w: user is blocked", ErrBlocked) + } + return nil +} diff --git a/Server/service/message_query.go b/Server/service/message_query.go new file mode 100644 index 00000000..ef28a5e2 --- /dev/null +++ b/Server/service/message_query.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// GetMessages retrieves paginated messages for a channel with permission checks. +func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { + if channelID <= 0 { + return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + } + + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound) + } + + // Permission check. + if ch.Type == "dm" { + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil || !ok { + return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) + } + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { + return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) + } + + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + // Fetch one extra to detect has_more. + msgs, err := s.st.GetMessagesForAPI(ctx, channelID, before, limit+1, userID) + if err != nil { + slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID) + return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal) + } + + hasMore := len(msgs) > limit + if hasMore { + msgs = msgs[:limit] + } + + return msgs, hasMore, nil +} + +// SearchMessages performs full-text search across accessible channels. +func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { + if query == "" { + return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest) + } + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + // Single-channel search. + if channelID != nil && *channelID > 0 { + ch, err := s.st.GetChannel(ctx, *channelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + if ch.Type == "dm" { + ok, err := s.st.IsDMParticipant(ctx, userID, *channelID) + if err != nil || !ok { + return nil, fmt.Errorf("%w: access denied", ErrForbidden) + } + } else if !s.perms.HasChannelPerm(ctx, userID, *channelID, permissions.ReadMessages) { + return nil, fmt.Errorf("%w: access denied", ErrForbidden) + } + results, err := s.st.SearchMessages(ctx, query, channelID, limit) + if err != nil { + return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) + } + return results, nil + } + + // Global search: build accessible channel list. + accessibleIDs, err := s.GetAccessibleChannelIDs(ctx, userID) + if err != nil { + return nil, err + } + if len(accessibleIDs) == 0 { + return nil, nil + } + + results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit) + if err != nil { + return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err) + } + return results, nil +} + +// GetPinnedMessages retrieves pinned messages for a channel. +func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { + if channelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + } + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + if ch.Type == "dm" { + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil || !ok { + return nil, fmt.Errorf("%w: access denied", ErrNotFound) + } + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { + return nil, fmt.Errorf("%w: access denied", ErrForbidden) + } + msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) + if err != nil { + return nil, fmt.Errorf("%w: failed to fetch pinned messages: %v", ErrInternal, err) + } + return msgs, nil +} + +// SetMessagePinned pins or unpins a message. +func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID, msgID int64, pinned bool) error { + if channelID <= 0 || msgID <= 0 { + return fmt.Errorf("%w: invalid IDs", ErrBadRequest) + } + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + if ch.Type == "dm" { + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil || !ok { + return fmt.Errorf("%w: access denied", ErrNotFound) + } + if blkErr := requireDMNotBlocked(ctx, s.st, userID, channelID); blkErr != nil { + return blkErr + } + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.ManageMessages) { + // Require READ_MESSAGES alongside MANAGE_MESSAGES so a role locked out + // of a private channel cannot mutate its pins — the admin panel's + // "Can access" toggle denies READ_MESSAGES|CONNECT_VOICE and leaves + // MANAGE_MESSAGES intact. Mirrors handleReaction and checkSendPermission. + return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden) + } + // Verify message belongs to this channel. + msg, err := s.st.GetMessage(ctx, msgID) + if err != nil || msg == nil || msg.ChannelID != channelID { + return fmt.Errorf("%w: message not found in this channel", ErrNotFound) + } + return s.st.SetMessagePinned(ctx, msgID, pinned) +} diff --git a/Server/service/message_reactions.go b/Server/service/message_reactions.go new file mode 100644 index 00000000..915c2bd8 --- /dev/null +++ b/Server/service/message_reactions.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/permissions" +) + +// AddReaction adds a reaction to a message. +func (s *MessageService) AddReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, true) +} + +// RemoveReaction removes a reaction from a message. +func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, false) +} + +func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { + // Rate limit. + ratKey := auth.Key("reaction", userID) + if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) { + return nil, ErrRateLimited + } + + if msgID <= 0 { + return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) + } + if emoji == "" || len([]rune(emoji)) > 32 { + return nil, fmt.Errorf("%w: invalid emoji", ErrBadRequest) + } + // Reject control characters. + for _, r := range emoji { + if r <= 0x1F || r == 0x7F { + return nil, fmt.Errorf("%w: emoji contains control characters", ErrBadRequest) + } + } + // Sanitize check. + if sanitizer.Sanitize(emoji) != emoji { + return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) + } + + msg, err := s.st.GetMessage(ctx, msgID) + if err != nil || msg == nil { + return nil, fmt.Errorf("%w: message not found", ErrBadRequest) + } + if msg.Deleted { + return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest) + } + + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) + isDM := chErr == nil && ch != nil && ch.Type == "dm" + + if isDM { + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) + if dmErr != nil || !ok { + return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) + } + if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil { + return nil, blkErr + } + } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { + // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot + // react in a channel they cannot read. Mirrors checkSendPermission, + // which requires ReadMessages|SendMessages for non-DM sends. + return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) + } + + action := "add" + if add { + if err := s.st.AddReaction(ctx, msgID, userID, emoji); err != nil { + slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID) + return nil, fmt.Errorf("%w: reaction already exists", ErrConflict) + } + } else { + action = "remove" + if err := s.st.RemoveReaction(ctx, msgID, userID, emoji); err != nil { + slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID) + return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest) + } + } + + result := &ReactionResult{ + MessageID: msgID, + ChannelID: msg.ChannelID, + UserID: userID, + Emoji: emoji, + Action: action, + IsDM: isDM, + } + + if isDM { + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) + if pErr != nil { + slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) + } else { + result.ParticipantIDs = participantIDs + } + } + + return result, nil +} diff --git a/Server/storage/storage.go b/Server/storage/storage.go index babaa6f4..53028e11 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -17,14 +17,14 @@ var blockedMagic = []struct { name string magic []byte }{ - {"PE executable", []byte("MZ")}, // Windows .exe / .dll - {"ELF binary", []byte("\x7fELF")}, // Linux binaries - {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit - {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit - {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) - {"Java class", []byte("\xca\xfe\xba\xbe")}, // .class files - {"OLE2 document", []byte("\xd0\xcf\x11\xe0")}, // .doc/.xls with macros - {"WebAssembly", []byte("\x00asm")}, // .wasm modules + {"PE executable", []byte("MZ")}, // Windows .exe / .dll + {"ELF binary", []byte("\x7fELF")}, // Linux binaries + {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit + {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit + {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) + {"Java class", []byte("\xca\xfe\xba\xbe")}, // .class files + {"OLE2 document", []byte("\xd0\xcf\x11\xe0")}, // .doc/.xls with macros + {"WebAssembly", []byte("\x00asm")}, // .wasm modules {"Windows shortcut", []byte{0x4c, 0x00, 0x00, 0x00}}, // .lnk files } diff --git a/Server/telemetry/metrics.go b/Server/telemetry/metrics.go index 5e70d4d5..fdff7b74 100644 --- a/Server/telemetry/metrics.go +++ b/Server/telemetry/metrics.go @@ -8,7 +8,10 @@ package telemetry -import "sync" +import ( + "sync" + "sync/atomic" +) const ( scopeWS = "github.com/owncord/server/ws" @@ -35,25 +38,34 @@ type AppMetrics struct { } var ( - appMetricsMu sync.Mutex - appMetricsInst *AppMetrics + appMetricsMu sync.Mutex // serializes construction and reset + appMetricsInst atomic.Pointer[AppMetrics] ) // NewAppMetrics returns a process-wide AppMetrics, lazily constructed against // the current global provider. Calling it multiple times returns the same // instance until resetAppMetricsForInit() is called (which Init uses after // swapping the global provider so instruments re-bind to the new meter). +// +// The steady-state path is a single atomic load — service-method defers call +// this per request, so it must not take a mutex once the bundle exists. A +// plain sync.Once would give the same fast path but cannot be re-armed by +// resetAppMetricsForInit, hence the pointer + construction mutex. func NewAppMetrics() *AppMetrics { + if m := appMetricsInst.Load(); m != nil { + return m + } appMetricsMu.Lock() defer appMetricsMu.Unlock() - if appMetricsInst != nil { - return appMetricsInst + // Re-check under the lock: another caller may have built it first. + if m := appMetricsInst.Load(); m != nil { + return m } ws := GlobalMeter(scopeWS) svc := GlobalMeter(scopeService) db := GlobalMeter(scopeDB) voice := GlobalMeter(scopeVoice) - appMetricsInst = &AppMetrics{ + m := &AppMetrics{ WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"), WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"), WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"), @@ -66,7 +78,8 @@ func NewAppMetrics() *AppMetrics { VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"), ServiceCallDurationSec: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"), } - return appMetricsInst + appMetricsInst.Store(m) + return m } // resetAppMetricsForInit drops the cached AppMetrics bundle so the next @@ -76,5 +89,5 @@ func NewAppMetrics() *AppMetrics { func resetAppMetricsForInit() { //nolint:unused // called by otel-tagged build only appMetricsMu.Lock() defer appMetricsMu.Unlock() - appMetricsInst = nil + appMetricsInst.Store(nil) } diff --git a/Server/token_cli.go b/Server/token_cli.go index 787f41b9..f3f5cb5d 100644 --- a/Server/token_cli.go +++ b/Server/token_cli.go @@ -24,7 +24,7 @@ func runTokenCLI(args []string) int { return 2 } - cfg, err := config.Load("config.yaml") + cfg, err := config.Load(config.DefaultPath) if err != nil { fmt.Fprintf(os.Stderr, "error: load config: %v\n", err) return 1 diff --git a/Server/updater/assets.go b/Server/updater/assets.go new file mode 100644 index 00000000..7629011b --- /dev/null +++ b/Server/updater/assets.go @@ -0,0 +1,212 @@ +package updater + +import ( + "context" + "fmt" + "io" + "net/http" + neturl "net/url" + "path" + "strings" + "time" +) + +// ClientAssets holds the URLs for Tauri client update artifacts. +type ClientAssets struct { + InstallerURL string + SignatureURL string +} + +// textAssetCacheEntry caches a small text asset (e.g. a client update .sig +// file) alongside the release cache so repeated requests are served from +// memory instead of re-fetching from GitHub on every call. +type textAssetCacheEntry struct { + content string + // err caches a failed fetch so an upstream outage is not re-dialled on + // every request. Cached errors expire after errorCacheTTL, successes + // after cacheTTL. + err error + expiry time.Time +} + +// clientAssetSuffixByTarget maps a Tauri updater target +// ("{os}-{arch}-{installer}") to the release asset suffix for that platform's +// updater artifact. The matching signature asset is the same suffix plus +// ".sig". Targets without a published updater artifact are absent — notably +// linux-*-deb: the release ships .deb packages but no signed deb updater +// artifact, and serving the AppImage archive instead would make the plugin's +// install_deb reject every update. +var clientAssetSuffixByTarget = map[string]string{ + "windows-x86_64-nsis": "_x64-setup.nsis.zip", + "linux-x86_64-appimage": "_amd64.AppImage.tar.gz", + "linux-aarch64-appimage": "_aarch64.AppImage.tar.gz", +} + +// FindClientAssets scans the cached release assets for the client updater +// artifact and its signature matching the given Tauri updater target +// (e.g. "windows-x86_64-nsis"). Unknown targets return empty ClientAssets. +func (u *Updater) FindClientAssets(target string) ClientAssets { + suffix, ok := clientAssetSuffixByTarget[target] + if !ok { + return ClientAssets{} + } + + u.mu.Lock() + defer u.mu.Unlock() + + if u.cache == nil { + return ClientAssets{} + } + + var ca ClientAssets + for _, a := range u.cache.Assets { + switch { + case strings.HasSuffix(a.Name, suffix+".sig"): + ca.SignatureURL = a.DownloadURL + case strings.HasSuffix(a.Name, suffix): + ca.InstallerURL = a.DownloadURL + } + } + return ca +} + +// FetchTextAsset downloads a small text asset (e.g. a .sig file) and returns +// its content as a string. +func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error) { + data, err := u.fetchBody(ctx, url) + if err != nil { + return "", err + } + return string(data), nil +} + +// FetchTextAssetCached is FetchTextAsset with an in-memory cache keyed by URL, +// using the same cacheTTL as the release cache. It lets unauthenticated, +// unrate-limited callers (e.g. the client-update endpoint) be served from +// memory instead of triggering an outbound fetch on every request. +func (u *Updater) FetchTextAssetCached(ctx context.Context, url string) (string, error) { + if entry, ok := u.lookupTextAsset(url, time.Now()); ok { + return entry.content, entry.err + } + + // Coalesce concurrent misses: when the TTL expires under load, every caller + // would otherwise issue its own outbound fetch. One flight per URL runs and + // the rest wait on its result. + // + // The flight is detached from the leader's ctx (see detachFetch): callers + // are the unauthenticated client-update endpoint, so a leader that aborts + // its request must not fail its followers or write its own + // context.Canceled into the shared negative cache. + v, err, _ := u.textAssetSF.Do(url, func() (any, error) { + now := time.Now() + // Re-check: another flight may have filled the cache while we queued. + if entry, ok := u.lookupTextAsset(url, now); ok { + return entry.content, entry.err + } + fetchCtx, cancel := detachFetch(ctx) + defer cancel() + content, fetchErr := u.FetchTextAsset(fetchCtx, url) + u.storeTextAsset(url, content, fetchErr, now) + return content, fetchErr + }) + if err != nil { + return "", err + } + return v.(string), nil +} + +// lookupTextAsset returns a live cache entry for url, if one exists. A cached +// entry may hold either content or an error; both are honoured until expiry. +func (u *Updater) lookupTextAsset(url string, now time.Time) (textAssetCacheEntry, bool) { + u.mu.Lock() + defer u.mu.Unlock() + entry, ok := u.textAssetCache[url] + if !ok || !now.Before(entry.expiry) { + return textAssetCacheEntry{}, false + } + return entry, true +} + +// storeTextAsset records the outcome of a fetch, caching failures briefly so an +// upstream outage does not trigger an outbound request per caller. +func (u *Updater) storeTextAsset(url, content string, err error, now time.Time) { + u.mu.Lock() + defer u.mu.Unlock() + if u.textAssetCache == nil { + u.textAssetCache = make(map[string]textAssetCacheEntry) + } + // Drop superseded keys: asset URLs carry a version, so without this the map + // grows by one entry per release for the lifetime of the process. + for k, e := range u.textAssetCache { + if !now.Before(e.expiry) { + delete(u.textAssetCache, k) + } + } + ttl := cacheTTL + if err != nil { + ttl = errorCacheTTL + } + u.textAssetCache[url] = textAssetCacheEntry{content: content, err: err, expiry: now.Add(ttl)} +} + +func assetFilenameFromURL(rawURL string) (string, error) { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return "", err + } + filename := path.Base(parsed.Path) + if filename == "." || filename == "/" || filename == "" { + return "", fmt.Errorf("missing asset filename in URL %q", rawURL) + } + return filename, nil +} + +// isGitHubHost reports whether the given URL points to a GitHub domain. +func isGitHubHost(rawURL string) bool { + u, err := neturl.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return host == "api.github.com" || host == "github.com" || + strings.HasSuffix(host, ".github.com") || + strings.HasSuffix(host, ".githubusercontent.com") +} + +// shouldSendToken reports whether the GitHub token should be attached to a +// request for the given URL. It returns true for GitHub hosts and for any URL +// that starts with the configured baseURL (which may be a test server override). +func (u *Updater) shouldSendToken(rawURL string) bool { + if isGitHubHost(rawURL) { + return true + } + if u.baseURL != "" && strings.HasPrefix(rawURL, u.baseURL) { + return true + } + return false +} + +// fetchBody performs a GET request and returns the response body as bytes. +func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if u.githubToken != "" && u.shouldSendToken(url) { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + + // Cap reads at 1 MiB — checksum and signature files are tiny text; + // this prevents a malicious or corrupted release asset from exhausting memory. + return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes)) +} diff --git a/Server/updater/download.go b/Server/updater/download.go new file mode 100644 index 00000000..aee235f7 --- /dev/null +++ b/Server/updater/download.go @@ -0,0 +1,320 @@ +package updater + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ValidateDownloadURL ensures the URL points to an expected GitHub release +// asset for this repository. +func (u *Updater) ValidateDownloadURL(url string) error { + prefix := fmt.Sprintf("https://github.com/%s/%s/releases/download/", u.repoOwner, u.repoName) + if !strings.HasPrefix(url, prefix) { + return fmt.Errorf("download URL %q does not match expected prefix %q", url, prefix) + } + return nil +} + +// DownloadAndVerify downloads the release artifact from downloadURL, fetches +// the checksum file, the detached binary signature, and a signed release +// manifest, and verifies that the downloaded asset matches both the release +// version and the pinned signing key. On Windows the asset is a single +// executable; on Linux it is a tar.gz archive containing a "chatserver" +// binary, which is extracted to destPath. On verification failure the +// downloaded file is removed. +// +// It returns the hex SHA256 of the staged binary at destPath, derived from +// the signed release manifest (on Linux, computed over the extracted bytes of +// the manifest-verified archive). Callers that later execute the staged file +// must re-verify it against this hash through an open handle +// (OpenVerifiedBinary), never by path. +func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, destPath string) (string, error) { + if err := u.ValidateDownloadURL(downloadURL); err != nil { + return "", err + } + 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) + } + if err := u.ValidateDownloadURL(manifestURL); err != nil { + return "", fmt.Errorf("validating manifest URL: %w", err) + } + if err := u.ValidateDownloadURL(manifestSignatureURL); err != nil { + return "", fmt.Errorf("validating manifest signature URL: %w", err) + } + + checksumData, err := u.fetchBody(ctx, checksumURL) + 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) + } + manifestData, err := u.fetchBody(ctx, manifestURL) + if err != nil { + return "", fmt.Errorf("fetching release manifest: %w", err) + } + manifestSignatureData, err := u.fetchBody(ctx, manifestSignatureURL) + if err != nil { + return "", fmt.Errorf("fetching release manifest signature: %w", err) + } + + assetFilename, err := assetFilenameFromURL(downloadURL) + if err != nil { + return "", fmt.Errorf("determining asset filename: %w", err) + } + manifest, err := u.VerifyReleaseManifest(manifestData, manifestSignatureData, latestVersion, assetFilename) + if err != nil { + return "", err + } + names := checksumEntryNamesForGOOS(runtime.GOOS) + if len(names) == 0 { + names = []string{assetFilename} + } + expectedHash, err := u.parseChecksumFileAny(checksumData, names...) + if err != nil { + return "", fmt.Errorf("parsing checksum file: %w", err) + } + if !strings.EqualFold(expectedHash, manifest.SHA256) { + return "", fmt.Errorf("release manifest checksum mismatch for %s", assetFilename) + } + + // Clear a stale staged binary from a previous aborted attempt. Staging is + // O_EXCL, so anything recreated at this path afterwards fails the download + // instead of being written through (TOCTOU). + _ = os.Remove(destPath) + + goos := runtime.GOOS + switch goos { + case "windows": + return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash, signatureData) + case "linux": + return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash) + default: + return "", fmt.Errorf("server auto-update is not supported on %s", goos) + } +} + +func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) (string, error) { + if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { + return "", fmt.Errorf("downloading binary: %w", err) + } + + if err := u.VerifySignature(destPath, signatureData); err != nil { + _ = os.Remove(destPath) + return "", err + } + + // Verify hash. + if err := u.VerifyChecksum(destPath, expectedHash); err != nil { + // Remove the invalid file. + _ = os.Remove(destPath) + return "", err + } + // The asset is the binary itself, so the manifest-bound hash is the + // staged binary's trusted hash. + return expectedHash, nil +} + +func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) (string, error) { + tarPath := destPath + ".tar.gz.partial" + _ = os.Remove(tarPath) // clear a stale partial; download stages O_EXCL + defer func() { _ = os.Remove(tarPath) }() + + if err := u.downloadFile(ctx, downloadURL, tarPath); err != nil { + return "", fmt.Errorf("downloading archive: %w", err) + } + + // Open the archive once and do both the checksum and the extraction + // through this one handle, so the bytes verified are the bytes extracted + // even if the path is swapped in between (TOCTOU). + f, err := os.Open(tarPath) + if err != nil { + return "", fmt.Errorf("opening archive: %w", err) + } + defer f.Close() //nolint:errcheck + + actual, err := readerSHA256(f) + if err != nil { + return "", fmt.Errorf("hashing archive: %w", err) + } + if !strings.EqualFold(actual, expectedHash) { + return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("rewinding archive: %w", err) + } + + binaryHash, err := extractChatserverFromTarGz(f, destPath) + if err != nil { + _ = os.Remove(destPath) + return "", fmt.Errorf("extracting archive: %w", err) + } + if err := os.Chmod(destPath, 0o755); err != nil { //nolint:gosec // G302: binary must be world-executable to run + return "", fmt.Errorf("chmod binary: %w", err) + } + return binaryHash, nil +} + +// extractChatserverFromTarGz extracts the "chatserver" entry from a tar.gz +// stream to destPath and returns the hex SHA256 of the bytes it wrote, so the +// caller gets a trusted hash of the staged binary without a path re-read. +// destPath is created O_EXCL: a pre-existing file (attacker-planted staging +// path) fails the extraction instead of being written through. +func extractChatserverFromTarGz(r io.Reader, destPath string) (string, error) { + gr, err := gzip.NewReader(r) + if err != nil { + return "", fmt.Errorf("gzip: %w", err) + } + defer gr.Close() //nolint:errcheck + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return "", fmt.Errorf("archive contains no file named chatserver") + } + if err != nil { + return "", fmt.Errorf("tar: %w", err) + } + skipBody := func() error { + if _, err := io.Copy(io.Discard, io.LimitReader(tr, hdr.Size)); err != nil { + return err + } + return nil + } + if hdr.Typeflag != tar.TypeReg { + if err := skipBody(); err != nil { + return "", err + } + continue + } + if strings.Contains(hdr.Name, "..") { + if err := skipBody(); err != nil { + return "", err + } + continue + } + if filepath.Base(hdr.Name) != "chatserver" { + if err := skipBody(); err != nil { + return "", err + } + continue + } + + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + return "", err + } + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, h), io.LimitReader(tr, hdr.Size)) + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(destPath) + return "", fmt.Errorf("writing binary: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(destPath) + return "", closeErr + } + if n != hdr.Size { + _ = os.Remove(destPath) + return "", fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size) + } + return hex.EncodeToString(h.Sum(nil)), nil + } +} + +// serverDownloadAssetName returns the GitHub release asset file name for the +// server binary on the given GOOS (windows, linux). Other values return "". +func serverDownloadAssetName(goos string) string { + switch goos { + case "windows": + return windowsServerBinary + case "linux": + return linuxServerArchive + default: + return "" + } +} + +// downloadFile downloads the content at url and writes it to destPath. +func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if u.githubToken != "" && u.shouldSendToken(url) { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + // O_EXCL: staging paths are predictable (exe + ".new"), so refuse to + // write through a pre-created file or symlink (TOCTOU). Callers remove + // stale staged files before downloading. + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("creating destination file: %w", err) + } + closed := false + defer func() { + if !closed { + _ = f.Close() + } + }() + + // Cap download at 500 MiB to prevent unbounded disk usage from a + // malicious or corrupted release asset. + const maxBinarySize = 500 * 1024 * 1024 + limitedReader := io.LimitReader(resp.Body, maxBinarySize) + + n, err := io.Copy(f, limitedReader) + if err != nil { + _ = f.Close() + closed = true + _ = os.Remove(destPath) + return fmt.Errorf("writing downloaded file: %w", err) + } + // Probe for one more byte to detect if the file exceeds the limit. + if n == maxBinarySize { + var probe [1]byte + if extra, _ := resp.Body.Read(probe[:]); extra > 0 { + _ = f.Close() + closed = true + _ = os.Remove(destPath) + return fmt.Errorf("downloaded file exceeds maximum size of %d bytes", maxBinarySize) + } + } + + // Explicitly close and check the error so a disk-full flush failure is + // not silently swallowed, which would leave a corrupt file on disk. + if err := f.Close(); err != nil { + closed = true + _ = os.Remove(destPath) + return fmt.Errorf("closing downloaded file: %w", err) + } + closed = true + return nil +} diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 1d27dab2..45d5ffe1 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -3,28 +3,14 @@ package updater import ( - "archive/tar" - "bytes" - "compress/gzip" "context" - "crypto/sha256" - _ "embed" - "encoding/base64" - "encoding/hex" "encoding/json" "fmt" - "io" "net/http" - neturl "net/url" - "os" - "path" - "path/filepath" "runtime" "strings" "time" - "aead.dev/minisign" - "github.com/owncord/server/config" "github.com/owncord/server/syncutil" @@ -54,15 +40,6 @@ const ( linuxServerArchive = "chatserver-linux-amd64.tar.gz" ) -// serverUpdatePublicKeyText is the pinned public key for server update -// signatures. Keep this file in sync with the SERVER_UPDATE_SIGNING_* CI -// secrets when rotating the server updater keypair. -// -//go:embed server_update_public_key.txt -var serverUpdatePublicKeyText string - -var defaultServerSignaturePublicKey = strings.TrimSpace(serverUpdatePublicKeyText) - // UpdateInfo holds the result of a version check. type UpdateInfo struct { Current string `json:"current"` @@ -79,35 +56,12 @@ type UpdateInfo struct { Assets []Asset `json:"assets,omitempty"` } -type releaseManifest struct { - Version string `json:"version"` - // Asset/SHA256 bind a single artifact. Releases before the multi-OS - // manifest bound only this pair; newer releases keep it pointing at the - // Windows binary so already-deployed servers can still verify and update. - Asset string `json:"asset"` - SHA256 string `json:"sha256"` - // Assets binds every server artifact the release ships (one per OS). - Assets []releaseManifestAsset `json:"assets,omitempty"` -} - -// releaseManifestAsset is one artifact binding in a multi-OS release manifest. -type releaseManifestAsset struct { - Asset string `json:"asset"` - SHA256 string `json:"sha256"` -} - // Asset is a simplified release asset with name and download URL. type Asset struct { Name string `json:"name"` DownloadURL string `json:"download_url"` } -// ClientAssets holds the URLs for Tauri client update artifacts. -type ClientAssets struct { - InstallerURL string - SignatureURL string -} - // releaseResponse mirrors the subset of GitHub's release API we need. type releaseResponse struct { TagName string `json:"tag_name"` @@ -141,18 +95,6 @@ type Updater struct { signingKeyText string } -// textAssetCacheEntry caches a small text asset (e.g. a client update .sig -// file) alongside the release cache so repeated requests are served from -// memory instead of re-fetching from GitHub on every call. -type textAssetCacheEntry struct { - content string - // err caches a failed fetch so an upstream outage is not re-dialled on - // every request. Cached errors expire after errorCacheTTL, successes - // after cacheTTL. - err error - expiry time.Time -} - // NewUpdater creates an Updater for the given repository. func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { return &Updater{ @@ -324,747 +266,3 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { func hasRequiredServerAssets(downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string) bool { return downloadURL != "" && checksumURL != "" && signatureURL != "" && manifestURL != "" && manifestSignatureURL != "" } - -// ValidateDownloadURL ensures the URL points to an expected GitHub release -// asset for this repository. -func (u *Updater) ValidateDownloadURL(url string) error { - prefix := fmt.Sprintf("https://github.com/%s/%s/releases/download/", u.repoOwner, u.repoName) - if !strings.HasPrefix(url, prefix) { - return fmt.Errorf("download URL %q does not match expected prefix %q", url, prefix) - } - return nil -} - -// DownloadAndVerify downloads the release artifact from downloadURL, fetches -// the checksum file, the detached binary signature, and a signed release -// manifest, and verifies that the downloaded asset matches both the release -// version and the pinned signing key. On Windows the asset is a single -// executable; on Linux it is a tar.gz archive containing a "chatserver" -// binary, which is extracted to destPath. On verification failure the -// downloaded file is removed. -// -// It returns the hex SHA256 of the staged binary at destPath, derived from -// the signed release manifest (on Linux, computed over the extracted bytes of -// the manifest-verified archive). Callers that later execute the staged file -// must re-verify it against this hash through an open handle -// (OpenVerifiedBinary), never by path. -func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, destPath string) (string, error) { - if err := u.ValidateDownloadURL(downloadURL); err != nil { - return "", err - } - 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) - } - if err := u.ValidateDownloadURL(manifestURL); err != nil { - return "", fmt.Errorf("validating manifest URL: %w", err) - } - if err := u.ValidateDownloadURL(manifestSignatureURL); err != nil { - return "", fmt.Errorf("validating manifest signature URL: %w", err) - } - - checksumData, err := u.fetchBody(ctx, checksumURL) - 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) - } - manifestData, err := u.fetchBody(ctx, manifestURL) - if err != nil { - return "", fmt.Errorf("fetching release manifest: %w", err) - } - manifestSignatureData, err := u.fetchBody(ctx, manifestSignatureURL) - if err != nil { - return "", fmt.Errorf("fetching release manifest signature: %w", err) - } - - assetFilename, err := assetFilenameFromURL(downloadURL) - if err != nil { - return "", fmt.Errorf("determining asset filename: %w", err) - } - manifest, err := u.VerifyReleaseManifest(manifestData, manifestSignatureData, latestVersion, assetFilename) - if err != nil { - return "", err - } - names := checksumEntryNamesForGOOS(runtime.GOOS) - if len(names) == 0 { - names = []string{assetFilename} - } - expectedHash, err := u.parseChecksumFileAny(checksumData, names...) - if err != nil { - return "", fmt.Errorf("parsing checksum file: %w", err) - } - if !strings.EqualFold(expectedHash, manifest.SHA256) { - return "", fmt.Errorf("release manifest checksum mismatch for %s", assetFilename) - } - - // Clear a stale staged binary from a previous aborted attempt. Staging is - // O_EXCL, so anything recreated at this path afterwards fails the download - // instead of being written through (TOCTOU). - _ = os.Remove(destPath) - - goos := runtime.GOOS - switch goos { - case "windows": - return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash, signatureData) - case "linux": - return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash) - default: - return "", fmt.Errorf("server auto-update is not supported on %s", goos) - } -} - -func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) (string, error) { - if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { - return "", fmt.Errorf("downloading binary: %w", err) - } - - if err := u.VerifySignature(destPath, signatureData); err != nil { - _ = os.Remove(destPath) - return "", err - } - - // Verify hash. - if err := u.VerifyChecksum(destPath, expectedHash); err != nil { - // Remove the invalid file. - _ = os.Remove(destPath) - return "", err - } - // The asset is the binary itself, so the manifest-bound hash is the - // staged binary's trusted hash. - return expectedHash, nil -} - -func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) (string, error) { - tarPath := destPath + ".tar.gz.partial" - _ = os.Remove(tarPath) // clear a stale partial; download stages O_EXCL - defer func() { _ = os.Remove(tarPath) }() - - if err := u.downloadFile(ctx, downloadURL, tarPath); err != nil { - return "", fmt.Errorf("downloading archive: %w", err) - } - - // Open the archive once and do both the checksum and the extraction - // through this one handle, so the bytes verified are the bytes extracted - // even if the path is swapped in between (TOCTOU). - f, err := os.Open(tarPath) - if err != nil { - return "", fmt.Errorf("opening archive: %w", err) - } - defer f.Close() //nolint:errcheck - - actual, err := readerSHA256(f) - if err != nil { - return "", fmt.Errorf("hashing archive: %w", err) - } - if !strings.EqualFold(actual, expectedHash) { - return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return "", fmt.Errorf("rewinding archive: %w", err) - } - - binaryHash, err := extractChatserverFromTarGz(f, destPath) - if err != nil { - _ = os.Remove(destPath) - return "", fmt.Errorf("extracting archive: %w", err) - } - if err := os.Chmod(destPath, 0o755); err != nil { //nolint:gosec // G302: binary must be world-executable to run - return "", fmt.Errorf("chmod binary: %w", err) - } - return binaryHash, nil -} - -// extractChatserverFromTarGz extracts the "chatserver" entry from a tar.gz -// stream to destPath and returns the hex SHA256 of the bytes it wrote, so the -// caller gets a trusted hash of the staged binary without a path re-read. -// destPath is created O_EXCL: a pre-existing file (attacker-planted staging -// path) fails the extraction instead of being written through. -func extractChatserverFromTarGz(r io.Reader, destPath string) (string, error) { - gr, err := gzip.NewReader(r) - if err != nil { - return "", fmt.Errorf("gzip: %w", err) - } - defer gr.Close() //nolint:errcheck - - tr := tar.NewReader(gr) - for { - hdr, err := tr.Next() - if err == io.EOF { - return "", fmt.Errorf("archive contains no file named chatserver") - } - if err != nil { - return "", fmt.Errorf("tar: %w", err) - } - skipBody := func() error { - if _, err := io.Copy(io.Discard, io.LimitReader(tr, hdr.Size)); err != nil { - return err - } - return nil - } - if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { - if err := skipBody(); err != nil { - return "", err - } - continue - } - if strings.Contains(hdr.Name, "..") { - if err := skipBody(); err != nil { - return "", err - } - continue - } - if filepath.Base(hdr.Name) != "chatserver" { - if err := skipBody(); err != nil { - return "", err - } - continue - } - - out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) - if err != nil { - return "", err - } - h := sha256.New() - n, copyErr := io.Copy(io.MultiWriter(out, h), io.LimitReader(tr, hdr.Size)) - closeErr := out.Close() - if copyErr != nil { - _ = os.Remove(destPath) - return "", fmt.Errorf("writing binary: %w", copyErr) - } - if closeErr != nil { - _ = os.Remove(destPath) - return "", closeErr - } - if n != hdr.Size { - _ = os.Remove(destPath) - return "", fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size) - } - return hex.EncodeToString(h.Sum(nil)), nil - } -} - -// serverDownloadAssetName returns the GitHub release asset file name for the -// server binary on the given GOOS (windows, linux). Other values return "". -func serverDownloadAssetName(goos string) string { - switch goos { - case "windows": - return windowsServerBinary - case "linux": - return linuxServerArchive - default: - return "" - } -} - -// checksumEntryNamesForGOOS returns sha256sum line suffixes to look up in -// checksums.sha256 (matches GitHub Actions release layout). -func checksumEntryNamesForGOOS(goos string) []string { - switch goos { - case "windows": - return []string{"windows/chatserver.exe", "chatserver.exe"} - case "linux": - return []string{"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"} - default: - return nil - } -} - -func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, error) { - for _, name := range names { - hash, err := u.ParseChecksumFile(data, name) - if err == nil { - return hash, nil - } - } - return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", ")) -} - -// VerifyReleaseManifest checks the detached signature on the release manifest -// and ensures the manifest binds the downloaded asset to the expected version. -func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) { - if err := u.verifySignatureReader(bytes.NewReader(manifestData), signatureText, manifestAsset); err != nil { - return releaseManifest{}, fmt.Errorf("verifying release manifest signature: %w", err) - } - - var manifest releaseManifest - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err) - } - manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version)) - - if manifest.Version == "v" { - return releaseManifest{}, fmt.Errorf("release manifest is missing required fields") - } - if manifest.Version != ensureVPrefix(expectedVersion) { - return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion)) - } - - // Candidate bindings: the per-OS assets list plus the legacy single-asset - // pair (the only binding manifests from older releases carry). - candidates := append([]releaseManifestAsset{}, manifest.Assets...) - candidates = append(candidates, releaseManifestAsset{Asset: manifest.Asset, SHA256: manifest.SHA256}) - for _, c := range candidates { - asset := strings.TrimSpace(c.Asset) - if asset == "" || asset != expectedAsset { - continue - } - sum := strings.ToLower(strings.TrimSpace(c.SHA256)) - if len(sum) != sha256.Size*2 { - return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", asset) - } - if _, err := hex.DecodeString(sum); err != nil { - return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", asset, err) - } - // Normalize the returned binding to the matched entry so callers can - // keep reading manifest.Asset/manifest.SHA256 regardless of schema. - manifest.Asset = asset - manifest.SHA256 = sum - return manifest, nil - } - return releaseManifest{}, fmt.Errorf("release manifest does not bind expected asset %q", expectedAsset) -} - -// VerifySignature checks whether the detached minisign signature matches the -// file contents using the pinned server-update public key. -func (u *Updater) VerifySignature(filePath string, signatureText []byte) error { - f, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("opening file for signature verification: %w", err) - } - defer f.Close() //nolint:errcheck - - return u.verifySignatureReader(f, signatureText, filepath.Base(filePath)) -} - -func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte, subject string) error { - publicKey, err := u.serverSignaturePublicKey() - if err != nil { - return fmt.Errorf("loading update signing key: %w", err) - } - - verifier := minisign.NewReader(reader) - if _, err := io.Copy(io.Discard, verifier); err != nil { - return fmt.Errorf("reading file for signature verification: %w", err) - } - - normalizedSig := normalizeSignatureText(signatureText) - var parsedSig minisign.Signature - if err := parsedSig.UnmarshalText(normalizedSig); err != nil { - return fmt.Errorf("invalid update signature format: %w", err) - } - - if !verifier.Verify(publicKey, normalizedSig) { - return fmt.Errorf("signature verification failed for %s", subject) - } - return nil -} - -// normalizeSignatureText returns the raw minisign signature document from -// signatureText. `tauri signer sign` emits .sig files that are base64-wrapped -// minisign documents (the same wrapping used for the pinned public key file); -// raw minisign documents pass through unchanged. -func normalizeSignatureText(signatureText []byte) []byte { - trimmed := []byte(strings.TrimSpace(string(signatureText))) - if bytes.HasPrefix(trimmed, []byte("untrusted comment:")) { - return trimmed - } - if decoded, err := base64.StdEncoding.DecodeString(string(trimmed)); err == nil { - return []byte(strings.TrimSpace(string(decoded))) - } - return trimmed -} - -func (u *Updater) serverSignaturePublicKey() (minisign.PublicKey, error) { - decoded, err := base64.StdEncoding.DecodeString(u.signingKeyText) - if err != nil { - return minisign.PublicKey{}, fmt.Errorf("decoding base64 public key: %w", err) - } - var publicKey minisign.PublicKey - if err := publicKey.UnmarshalText(decoded); err != nil { - return minisign.PublicKey{}, fmt.Errorf("parsing minisign public key: %w", err) - } - return publicKey, nil -} - -func assetFilenameFromURL(rawURL string) (string, error) { - parsed, err := neturl.Parse(rawURL) - if err != nil { - return "", err - } - filename := path.Base(parsed.Path) - if filename == "." || filename == "/" || filename == "" { - return "", fmt.Errorf("missing asset filename in URL %q", rawURL) - } - return filename, nil -} - -// readerSHA256 returns the hex-encoded SHA256 of everything read from r. -func readerSHA256(r io.Reader) (string, error) { - h := sha256.New() - if _, err := io.Copy(h, r); err != nil { - return "", fmt.Errorf("computing checksum: %w", err) - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -// fileSHA256 returns the hex-encoded SHA256 of the file at path. -func fileSHA256(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", fmt.Errorf("opening file for checksum: %w", err) - } - defer f.Close() //nolint:errcheck - - return readerSHA256(f) -} - -// VerifyChecksum computes the SHA256 hash of the file at filePath and -// compares it (case-insensitive) against expectedHash. -func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { - actual, err := fileSHA256(filePath) - if err != nil { - return err - } - if !strings.EqualFold(actual, expectedHash) { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) - } - return nil -} - -// StagedBinary is an open handle to a staged update binary whose contents -// were verified through that same handle. Because the hash check and Commit's -// same-file check use one open file, a swap of the on-disk path between -// verification and rename is detected instead of silently executed (the -// update TOCTOU window, W3-3). -type StagedBinary struct { - f *os.File - closed bool -} - -// OpenVerifiedBinary opens stagedPath exactly once and verifies the SHA256 of -// its contents through that handle against expectedHash (hex, -// case-insensitive). On success the returned StagedBinary keeps the handle -// open for Commit; the caller must Close it. -func OpenVerifiedBinary(stagedPath, expectedHash string) (*StagedBinary, error) { - f, err := os.Open(stagedPath) - if err != nil { - return nil, fmt.Errorf("opening staged binary: %w", err) - } - actual, err := readerSHA256(f) - if err != nil { - _ = f.Close() - return nil, fmt.Errorf("hashing staged binary: %w", err) - } - if !strings.EqualFold(actual, expectedHash) { - _ = f.Close() - return nil, fmt.Errorf("staged binary checksum mismatch: expected %s, got %s", expectedHash, actual) - } - return &StagedBinary{f: f}, nil -} - -// Commit renames the staged file to destPath and confirms the file now at -// destPath is the very file the hash was verified through (os.SameFile -// against the verification handle's identity). If the staged path was swapped -// after verification, the rename moves the impostor, the same-file check -// fails, and Commit returns an error; the caller must then treat destPath as -// unverified and restore or remove it. -func (s *StagedBinary) Commit(destPath string) error { - verified, err := s.f.Stat() - if err != nil { - return fmt.Errorf("stat of verified handle: %w", err) - } - if runtime.GOOS == "windows" { - // Windows cannot rename a file Go holds open (os.Open does not share - // delete) — until here that lock itself blocks swaps of the staged - // path. The stat captured above carries the NTFS file ID, which - // travels with the file across the rename, so the same-file check - // below still detects a swap in the close→rename window. - if err := s.Close(); err != nil { - return fmt.Errorf("closing verified handle: %w", err) - } - } - // On Unix the handle stays open through the rename: a held fd also pins - // the verified inode, so its number cannot be reused by another file. - if err := os.Rename(s.f.Name(), destPath); err != nil { - return fmt.Errorf("renaming staged binary: %w", err) - } - committed, err := os.Lstat(destPath) - if err != nil { - return fmt.Errorf("stat of committed binary: %w", err) - } - if !os.SameFile(verified, committed) { - return fmt.Errorf("staged binary was replaced after verification (refusing to run it)") - } - return nil -} - -// Close releases the verification handle. Safe to call more than once. -func (s *StagedBinary) Close() error { - if s.closed { - return nil - } - s.closed = true - return s.f.Close() -} - -// ParseChecksumFile parses a sha256sum-format checksum file (lines of -// " ") and returns the hash for the given filename. -func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { - lines := strings.SplitSeq(string(data), "\n") - for line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // sha256sum format: " " (two spaces) - // Also handle single-space separation for robustness. - parts := strings.Fields(line) - if len(parts) >= 2 && parts[len(parts)-1] == filename { - return parts[0], nil - } - } - return "", fmt.Errorf("file %q not found in checksum data", filename) -} - -// isGitHubHost reports whether the given URL points to a GitHub domain. -func isGitHubHost(rawURL string) bool { - u, err := neturl.Parse(rawURL) - if err != nil { - return false - } - host := strings.ToLower(u.Hostname()) - return host == "api.github.com" || host == "github.com" || - strings.HasSuffix(host, ".github.com") || - strings.HasSuffix(host, ".githubusercontent.com") -} - -// shouldSendToken reports whether the GitHub token should be attached to a -// request for the given URL. It returns true for GitHub hosts and for any URL -// that starts with the configured baseURL (which may be a test server override). -func (u *Updater) shouldSendToken(rawURL string) bool { - if isGitHubHost(rawURL) { - return true - } - if u.baseURL != "" && strings.HasPrefix(rawURL, u.baseURL) { - return true - } - return false -} - -// fetchBody performs a GET request and returns the response body as bytes. -func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - if u.githubToken != "" && u.shouldSendToken(url) { - req.Header.Set("Authorization", "token "+u.githubToken) - } - - resp, err := u.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) - } - - // Cap reads at 1 MiB — checksum and signature files are tiny text; - // this prevents a malicious or corrupted release asset from exhausting memory. - return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes)) -} - -// clientAssetSuffixByTarget maps a Tauri updater target -// ("{os}-{arch}-{installer}") to the release asset suffix for that platform's -// updater artifact. The matching signature asset is the same suffix plus -// ".sig". Targets without a published updater artifact are absent — notably -// linux-*-deb: the release ships .deb packages but no signed deb updater -// artifact, and serving the AppImage archive instead would make the plugin's -// install_deb reject every update. -var clientAssetSuffixByTarget = map[string]string{ - "windows-x86_64-nsis": "_x64-setup.nsis.zip", - "linux-x86_64-appimage": "_amd64.AppImage.tar.gz", - "linux-aarch64-appimage": "_aarch64.AppImage.tar.gz", -} - -// FindClientAssets scans the cached release assets for the client updater -// artifact and its signature matching the given Tauri updater target -// (e.g. "windows-x86_64-nsis"). Unknown targets return empty ClientAssets. -func (u *Updater) FindClientAssets(target string) ClientAssets { - suffix, ok := clientAssetSuffixByTarget[target] - if !ok { - return ClientAssets{} - } - - u.mu.Lock() - defer u.mu.Unlock() - - if u.cache == nil { - return ClientAssets{} - } - - var ca ClientAssets - for _, a := range u.cache.Assets { - switch { - case strings.HasSuffix(a.Name, suffix+".sig"): - ca.SignatureURL = a.DownloadURL - case strings.HasSuffix(a.Name, suffix): - ca.InstallerURL = a.DownloadURL - } - } - return ca -} - -// FetchTextAsset downloads a small text asset (e.g. a .sig file) and returns -// its content as a string. -func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error) { - data, err := u.fetchBody(ctx, url) - if err != nil { - return "", err - } - return string(data), nil -} - -// FetchTextAssetCached is FetchTextAsset with an in-memory cache keyed by URL, -// using the same cacheTTL as the release cache. It lets unauthenticated, -// unrate-limited callers (e.g. the client-update endpoint) be served from -// memory instead of triggering an outbound fetch on every request. -func (u *Updater) FetchTextAssetCached(ctx context.Context, url string) (string, error) { - if entry, ok := u.lookupTextAsset(url, time.Now()); ok { - return entry.content, entry.err - } - - // Coalesce concurrent misses: when the TTL expires under load, every caller - // would otherwise issue its own outbound fetch. One flight per URL runs and - // the rest wait on its result. - // - // The flight is detached from the leader's ctx (see detachFetch): callers - // are the unauthenticated client-update endpoint, so a leader that aborts - // its request must not fail its followers or write its own - // context.Canceled into the shared negative cache. - v, err, _ := u.textAssetSF.Do(url, func() (any, error) { - now := time.Now() - // Re-check: another flight may have filled the cache while we queued. - if entry, ok := u.lookupTextAsset(url, now); ok { - return entry.content, entry.err - } - fetchCtx, cancel := detachFetch(ctx) - defer cancel() - content, fetchErr := u.FetchTextAsset(fetchCtx, url) - u.storeTextAsset(url, content, fetchErr, now) - return content, fetchErr - }) - if err != nil { - return "", err - } - return v.(string), nil -} - -// lookupTextAsset returns a live cache entry for url, if one exists. A cached -// entry may hold either content or an error; both are honoured until expiry. -func (u *Updater) lookupTextAsset(url string, now time.Time) (textAssetCacheEntry, bool) { - u.mu.Lock() - defer u.mu.Unlock() - entry, ok := u.textAssetCache[url] - if !ok || !now.Before(entry.expiry) { - return textAssetCacheEntry{}, false - } - return entry, true -} - -// storeTextAsset records the outcome of a fetch, caching failures briefly so an -// upstream outage does not trigger an outbound request per caller. -func (u *Updater) storeTextAsset(url, content string, err error, now time.Time) { - u.mu.Lock() - defer u.mu.Unlock() - if u.textAssetCache == nil { - u.textAssetCache = make(map[string]textAssetCacheEntry) - } - // Drop superseded keys: asset URLs carry a version, so without this the map - // grows by one entry per release for the lifetime of the process. - for k, e := range u.textAssetCache { - if !now.Before(e.expiry) { - delete(u.textAssetCache, k) - } - } - ttl := cacheTTL - if err != nil { - ttl = errorCacheTTL - } - u.textAssetCache[url] = textAssetCacheEntry{content: content, err: err, expiry: now.Add(ttl)} -} - -// downloadFile downloads the content at url and writes it to destPath. -func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - if u.githubToken != "" && u.shouldSendToken(url) { - req.Header.Set("Authorization", "token "+u.githubToken) - } - - resp, err := u.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) - } - - // O_EXCL: staging paths are predictable (exe + ".new"), so refuse to - // write through a pre-created file or symlink (TOCTOU). Callers remove - // stale staged files before downloading. - f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return fmt.Errorf("creating destination file: %w", err) - } - closed := false - defer func() { - if !closed { - _ = f.Close() - } - }() - - // Cap download at 500 MiB to prevent unbounded disk usage from a - // malicious or corrupted release asset. - const maxBinarySize = 500 * 1024 * 1024 - limitedReader := io.LimitReader(resp.Body, maxBinarySize) - - n, err := io.Copy(f, limitedReader) - if err != nil { - _ = f.Close() - closed = true - _ = os.Remove(destPath) - return fmt.Errorf("writing downloaded file: %w", err) - } - // Probe for one more byte to detect if the file exceeds the limit. - if n == maxBinarySize { - var probe [1]byte - if extra, _ := resp.Body.Read(probe[:]); extra > 0 { - _ = f.Close() - closed = true - _ = os.Remove(destPath) - return fmt.Errorf("downloaded file exceeds maximum size of %d bytes", maxBinarySize) - } - } - - // Explicitly close and check the error so a disk-full flush failure is - // not silently swallowed, which would leave a corrupt file on disk. - if err := f.Close(); err != nil { - closed = true - _ = os.Remove(destPath) - return fmt.Errorf("closing downloaded file: %w", err) - } - closed = true - return nil -} diff --git a/Server/updater/verify.go b/Server/updater/verify.go new file mode 100644 index 00000000..621ecbb9 --- /dev/null +++ b/Server/updater/verify.go @@ -0,0 +1,302 @@ +package updater + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "aead.dev/minisign" +) + +// serverUpdatePublicKeyText is the pinned public key for server update +// signatures. Keep this file in sync with the SERVER_UPDATE_SIGNING_* CI +// secrets when rotating the server updater keypair. +// +//go:embed server_update_public_key.txt +var serverUpdatePublicKeyText string + +var defaultServerSignaturePublicKey = strings.TrimSpace(serverUpdatePublicKeyText) + +type releaseManifest struct { + Version string `json:"version"` + // Asset/SHA256 bind a single artifact. Releases before the multi-OS + // manifest bound only this pair; newer releases keep it pointing at the + // Windows binary so already-deployed servers can still verify and update. + Asset string `json:"asset"` + SHA256 string `json:"sha256"` + // Assets binds every server artifact the release ships (one per OS). + Assets []releaseManifestAsset `json:"assets,omitempty"` +} + +// releaseManifestAsset is one artifact binding in a multi-OS release manifest. +type releaseManifestAsset struct { + Asset string `json:"asset"` + SHA256 string `json:"sha256"` +} + +// checksumEntryNamesForGOOS returns sha256sum line suffixes to look up in +// checksums.sha256 (matches GitHub Actions release layout). +func checksumEntryNamesForGOOS(goos string) []string { + switch goos { + case "windows": + return []string{"windows/chatserver.exe", "chatserver.exe"} + case "linux": + return []string{"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"} + default: + return nil + } +} + +func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, error) { + for _, name := range names { + hash, err := u.ParseChecksumFile(data, name) + if err == nil { + return hash, nil + } + } + return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", ")) +} + +// VerifyReleaseManifest checks the detached signature on the release manifest +// and ensures the manifest binds the downloaded asset to the expected version. +func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) { + if err := u.verifySignatureReader(bytes.NewReader(manifestData), signatureText, manifestAsset); err != nil { + return releaseManifest{}, fmt.Errorf("verifying release manifest signature: %w", err) + } + + var manifest releaseManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err) + } + manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version)) + + if manifest.Version == "v" { + return releaseManifest{}, fmt.Errorf("release manifest is missing required fields") + } + if manifest.Version != ensureVPrefix(expectedVersion) { + return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion)) + } + + // Candidate bindings: the per-OS assets list plus the legacy single-asset + // pair (the only binding manifests from older releases carry). + candidates := append([]releaseManifestAsset{}, manifest.Assets...) + candidates = append(candidates, releaseManifestAsset{Asset: manifest.Asset, SHA256: manifest.SHA256}) + for _, c := range candidates { + asset := strings.TrimSpace(c.Asset) + if asset == "" || asset != expectedAsset { + continue + } + sum := strings.ToLower(strings.TrimSpace(c.SHA256)) + if len(sum) != sha256.Size*2 { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", asset) + } + if _, err := hex.DecodeString(sum); err != nil { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", asset, err) + } + // Normalize the returned binding to the matched entry so callers can + // keep reading manifest.Asset/manifest.SHA256 regardless of schema. + manifest.Asset = asset + manifest.SHA256 = sum + return manifest, nil + } + return releaseManifest{}, fmt.Errorf("release manifest does not bind expected asset %q", expectedAsset) +} + +// VerifySignature checks whether the detached minisign signature matches the +// file contents using the pinned server-update public key. +func (u *Updater) VerifySignature(filePath string, signatureText []byte) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for signature verification: %w", err) + } + defer f.Close() //nolint:errcheck + + return u.verifySignatureReader(f, signatureText, filepath.Base(filePath)) +} + +func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte, subject string) error { + publicKey, err := u.serverSignaturePublicKey() + if err != nil { + return fmt.Errorf("loading update signing key: %w", err) + } + + verifier := minisign.NewReader(reader) + if _, err := io.Copy(io.Discard, verifier); err != nil { + return fmt.Errorf("reading file for signature verification: %w", err) + } + + normalizedSig := normalizeSignatureText(signatureText) + var parsedSig minisign.Signature + if err := parsedSig.UnmarshalText(normalizedSig); err != nil { + return fmt.Errorf("invalid update signature format: %w", err) + } + + if !verifier.Verify(publicKey, normalizedSig) { + return fmt.Errorf("signature verification failed for %s", subject) + } + return nil +} + +// normalizeSignatureText returns the raw minisign signature document from +// signatureText. `tauri signer sign` emits .sig files that are base64-wrapped +// minisign documents (the same wrapping used for the pinned public key file); +// raw minisign documents pass through unchanged. +func normalizeSignatureText(signatureText []byte) []byte { + trimmed := []byte(strings.TrimSpace(string(signatureText))) + if bytes.HasPrefix(trimmed, []byte("untrusted comment:")) { + return trimmed + } + if decoded, err := base64.StdEncoding.DecodeString(string(trimmed)); err == nil { + return []byte(strings.TrimSpace(string(decoded))) + } + return trimmed +} + +func (u *Updater) serverSignaturePublicKey() (minisign.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(u.signingKeyText) + if err != nil { + return minisign.PublicKey{}, fmt.Errorf("decoding base64 public key: %w", err) + } + var publicKey minisign.PublicKey + if err := publicKey.UnmarshalText(decoded); err != nil { + return minisign.PublicKey{}, fmt.Errorf("parsing minisign public key: %w", err) + } + return publicKey, nil +} + +// readerSHA256 returns the hex-encoded SHA256 of everything read from r. +func readerSHA256(r io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return "", fmt.Errorf("computing checksum: %w", err) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// fileSHA256 returns the hex-encoded SHA256 of the file at path. +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("opening file for checksum: %w", err) + } + defer f.Close() //nolint:errcheck + + return readerSHA256(f) +} + +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + actual, err := fileSHA256(filePath) + if err != nil { + return err + } + if !strings.EqualFold(actual, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +// StagedBinary is an open handle to a staged update binary whose contents +// were verified through that same handle. Because the hash check and Commit's +// same-file check use one open file, a swap of the on-disk path between +// verification and rename is detected instead of silently executed (the +// update TOCTOU window, W3-3). +type StagedBinary struct { + f *os.File + closed bool +} + +// OpenVerifiedBinary opens stagedPath exactly once and verifies the SHA256 of +// its contents through that handle against expectedHash (hex, +// case-insensitive). On success the returned StagedBinary keeps the handle +// open for Commit; the caller must Close it. +func OpenVerifiedBinary(stagedPath, expectedHash string) (*StagedBinary, error) { + f, err := os.Open(stagedPath) + if err != nil { + return nil, fmt.Errorf("opening staged binary: %w", err) + } + actual, err := readerSHA256(f) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("hashing staged binary: %w", err) + } + if !strings.EqualFold(actual, expectedHash) { + _ = f.Close() + return nil, fmt.Errorf("staged binary checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return &StagedBinary{f: f}, nil +} + +// Commit renames the staged file to destPath and confirms the file now at +// destPath is the very file the hash was verified through (os.SameFile +// against the verification handle's identity). If the staged path was swapped +// after verification, the rename moves the impostor, the same-file check +// fails, and Commit returns an error; the caller must then treat destPath as +// unverified and restore or remove it. +func (s *StagedBinary) Commit(destPath string) error { + verified, err := s.f.Stat() + if err != nil { + return fmt.Errorf("stat of verified handle: %w", err) + } + if runtime.GOOS == "windows" { + // Windows cannot rename a file Go holds open (os.Open does not share + // delete) — until here that lock itself blocks swaps of the staged + // path. The stat captured above carries the NTFS file ID, which + // travels with the file across the rename, so the same-file check + // below still detects a swap in the close→rename window. + if err := s.Close(); err != nil { + return fmt.Errorf("closing verified handle: %w", err) + } + } + // On Unix the handle stays open through the rename: a held fd also pins + // the verified inode, so its number cannot be reused by another file. + if err := os.Rename(s.f.Name(), destPath); err != nil { + return fmt.Errorf("renaming staged binary: %w", err) + } + committed, err := os.Lstat(destPath) + if err != nil { + return fmt.Errorf("stat of committed binary: %w", err) + } + if !os.SameFile(verified, committed) { + return fmt.Errorf("staged binary was replaced after verification (refusing to run it)") + } + return nil +} + +// Close releases the verification handle. Safe to call more than once. +func (s *StagedBinary) Close() error { + if s.closed { + return nil + } + s.closed = true + return s.f.Close() +} + +// ParseChecksumFile parses a sha256sum-format checksum file (lines of +// " ") and returns the hash for the given filename. +func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { + lines := strings.SplitSeq(string(data), "\n") + for line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // sha256sum format: " " (two spaces) + // Also handle single-space separation for robustness. + parts := strings.Fields(line) + if len(parts) >= 2 && parts[len(parts)-1] == filename { + return parts[0], nil + } + } + return "", fmt.Errorf("file %q not found in checksum data", filename) +} diff --git a/Server/ws/authz_test.go b/Server/ws/authz_test.go index ef157e7d..ce73badf 100644 --- a/Server/ws/authz_test.go +++ b/Server/ws/authz_test.go @@ -49,13 +49,12 @@ func TestChannelFocus_AllowedByDefault(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, channelFocusMsg(chID)) - time.Sleep(50 * time.Millisecond) // Should NOT receive a FORBIDDEN error. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -84,10 +83,9 @@ func TestChannelFocus_DeniedByOverride(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, channelFocusMsg(chID)) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { @@ -108,13 +106,12 @@ func TestChannelFocus_AdminBypassesDeny(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, channelFocusMsg(chID)) - time.Sleep(50 * time.Millisecond) // Should NOT receive a FORBIDDEN error. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -143,10 +140,9 @@ func TestChatSend_DeniedWithoutSendMessages(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatSendMsg(chID, "should be rejected")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { diff --git a/Server/ws/coverage_boost2_test.go b/Server/ws/coverage_boost2_test.go index 14877dfc..bf04e365 100644 --- a/Server/ws/coverage_boost2_test.go +++ b/Server/ws/coverage_boost2_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "testing" - "time" "github.com/owncord/server/db" "github.com/owncord/server/ws" @@ -26,7 +25,7 @@ func TestIsUserConnected_Connected(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) if !hub.IsUserConnected(user.ID) { t.Error("expected true for registered user") @@ -39,10 +38,11 @@ func TestIsUserConnected_AfterUnregister(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.Unregister(c) - time.Sleep(20 * time.Millisecond) + waitFor(t, waitTimeout, func() bool { return !hub.IsUserConnected(user.ID) }, + "client to be unregistered") if hub.IsUserConnected(user.ID) { t.Error("expected false after unregister") @@ -177,7 +177,7 @@ func TestHandleVoiceMute_NotInVoice2(t *testing.T) { hub.HandleMessageForTest(c, raw) // Should receive an error about not being in a voice channel. - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -210,7 +210,7 @@ func TestHandleVoiceDeafen_NotInVoice2(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_deafen", "payload": json.RawMessage(payload)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -240,7 +240,7 @@ func TestHandleVoiceCamera_NotInVoice2(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_camera", "payload": json.RawMessage(payload)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -270,7 +270,7 @@ func TestHandleVoiceScreenshare_NotInVoice2(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_screenshare", "payload": json.RawMessage(payload)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -303,7 +303,7 @@ func TestHandleVoiceMute_BadPayload(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_mute", "payload": json.RawMessage(`{invalid json`)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -334,7 +334,7 @@ func TestHandleVoiceDeafen_BadPayload(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_deafen", "payload": json.RawMessage(`not json`)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -368,7 +368,7 @@ func TestHandleVoiceCamera_BadPayload(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_camera", "payload": json.RawMessage(`{bad`)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send @@ -402,7 +402,7 @@ func TestHandleVoiceScreenshare_BadPayload(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "voice_screenshare", "payload": json.RawMessage(`{bad`)}) hub.HandleMessageForTest(c, raw) - time.Sleep(10 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. found := false for len(send) > 0 { msg := <-send diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go deleted file mode 100644 index 4ff97ba6..00000000 --- a/Server/ws/coverage_boost_test.go +++ /dev/null @@ -1,2856 +0,0 @@ -package ws_test - -// coverage_boost_test.go adds tests for functions with 0% or low coverage -// to push the ws package above 80%. - -import ( - "context" - "encoding/json" - "math" - "strings" - "testing" - "testing/fstest" - "time" - - "github.com/owncord/server/auth" - "github.com/owncord/server/config" - "github.com/owncord/server/db" - "github.com/owncord/server/permissions" - "github.com/owncord/server/service" - "github.com/owncord/server/ws" -) - -// ─── schema with voice_states + audit_log for coverage tests ────────────────── - -var coverageSchema = append(hubTestSchema, []byte(` -CREATE TABLE IF NOT EXISTS 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, - camera INTEGER NOT NULL DEFAULT 0, - screenshare INTEGER NOT NULL DEFAULT 0, - joined_at TEXT NOT NULL DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_voice_states_channel_cov ON voice_states(channel_id); - -CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - actor_id INTEGER NOT NULL REFERENCES users(id), - action TEXT NOT NULL, - target_type TEXT NOT NULL DEFAULT '', - target_id INTEGER NOT NULL DEFAULT 0, - detail TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, - message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, - uploader_id INTEGER REFERENCES users(id), - 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 -); - -`)...) - -func openCoverageDB(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() }) - migrFS := fstest.MapFS{ - "001_schema.sql": {Data: coverageSchema}, - } - if err := db.MigrateFS(database, migrFS); err != nil { - t.Fatalf("MigrateFS: %v", err) - } - return database -} - -func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { - t.Helper() - database := openCoverageDB(t) - limiter := auth.NewRateLimiter() - st := database - svc := service.New(st, limiter) - hub := ws.NewHub(database, limiter, svc) - - // Inject a test LiveKit client so voice_join passes the livekit!=nil guard. - lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ - LiveKitAPIKey: "test-api-key-12345", - LiveKitAPISecret: "test-api-secret-67890abcdef", - LiveKitURL: "ws://localhost:7880", - }) - if err != nil { - t.Fatalf("NewLiveKitClient: %v", err) - } - hub.SetLiveKit(lk) - - go hub.Run() - t.Cleanup(func() { hub.Stop() }) - return hub, database -} - -func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User { - t.Helper() - _, err := database.CreateUser(context.Background(), username, "hash", 1) - if err != nil { - t.Fatalf("seedCoverageOwner CreateUser: %v", err) - } - user, err := database.GetUserByUsername(context.Background(), username) - if err != nil || user == nil { - t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err) - } - return user -} - -// ─── SetClientVoiceChID stores the tracked voice channel ───────────────────── - -func TestSetClientVoiceChID_SetsValue(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetClientVoiceChID(c, 42) - if got := ws.GetClientVoiceChIDForTest(c); got != 42 { - t.Fatalf("voiceChID = %d, want 42", got) - } -} - -func TestSetClientVoiceChID_ZeroClearsVoice(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetClientVoiceChID(c, 100) - ws.SetClientVoiceChID(c, 0) - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID = %d, want 0", got) - } -} - -func TestSetClientVoiceChID_LastWriteWins(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetClientVoiceChID(c, 7) - ws.SetClientVoiceChID(c, 99) - if got := ws.GetClientVoiceChIDForTest(c); got != 99 { - t.Fatalf("voiceChID = %d, want 99", got) - } -} - -// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ──────────────── - -func TestBuildJSON_UnmarshalableValue_ReturnsFallback(t *testing.T) { - // math.Inf is not valid JSON — forces the error path in buildJSON. - out := ws.BuildJSONForTest(math.Inf(1)) - if !json.Valid(out) { - t.Fatalf("fallback output is not valid JSON: %s", out) - } - var m map[string]string - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("unmarshal fallback: %v", err) - } - if m["type"] != "error" { - t.Errorf("fallback type = %q, want error", m["type"]) - } - if m["message"] != "internal marshal error" { - t.Errorf("fallback message = %q, want 'internal marshal error'", m["message"]) - } -} - -func TestBuildJSON_ChannelValue_ReturnsFallback(t *testing.T) { - // Channels are not JSON-marshalable. - out := ws.BuildJSONForTest(make(chan int)) - if !json.Valid(out) { - t.Fatalf("fallback output is not valid JSON: %s", out) - } -} - -// ─── GracefulStop with clients having voice state (hub.go:188 — 75%) ───────── - -func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) { - hub, database := newCoverageHub(t) - - user := seedCoverageOwner(t, database, "graceful-voice-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Set voice channel ID on the client to simulate voice state. - ws.SetClientVoiceChID(c, 42) - - if count := hub.ClientCount(); count != 1 { - t.Fatalf("before GracefulStop: client count = %d, want 1", count) - } - if got := ws.GetClientVoiceChIDForTest(c); got != 42 { - t.Fatalf("voiceChID before stop = %d, want 42", got) - } - - hub.GracefulStop() - time.Sleep(20 * time.Millisecond) - // GracefulStop signals clients to close — test clients don't have real - // goroutines so they won't self-unregister, but verify the hub accepted - // the stop without deadlocking on voice-state cleanup. -} - -func TestGracefulStop_MultipleClients(t *testing.T) { - hub, database := newCoverageHub(t) - - for i := range 5 { - user := seedCoverageOwner(t, database, "graceful-multi-"+string(rune('a'+i))) - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - } - time.Sleep(30 * time.Millisecond) - - if count := hub.ClientCount(); count != 5 { - t.Fatalf("before GracefulStop: client count = %d, want 5", count) - } - - hub.GracefulStop() - time.Sleep(20 * time.Millisecond) - // Verify GracefulStop completes without deadlock on multiple clients. -} - -// ─── handleChatSend additional branches (handlers.go:127 — 76.2%) ──────────── - -func TestHandleChatSend_EmptyContent(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "empty-content-user") - chID := seedTestChannel(t, database, "empty-content-chan") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": "", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for empty content", code) - } -} - -func TestHandleChatSend_ContentTooLong(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "long-content-user") - chID := seedTestChannel(t, database, "long-content-chan") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Content over 4000 characters. - longContent := strings.Repeat("x", 4001) - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": longContent, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for content too long", code) - } -} - -func TestHandleChatSend_InvalidChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bad-chid-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": "not-a-number", - "content": "hello", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid channel_id", code) - } -} - -func TestHandleChatSend_ChannelNotFound(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "notfound-chan-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": 99999, - "content": "hello", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "NOT_FOUND" { - t.Errorf("error code = %q, want NOT_FOUND for nonexistent channel", code) - } -} - -func TestHandleChatSend_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bad-payload-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid payload", code) - } -} - -func TestHandleChatSend_NegativeChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "neg-chid-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": -1, - "content": "hello", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for negative channel_id", code) - } -} - -// ─── handleChatSend with reply_to (handlers.go:127 — covers reply_to path) ── - -func TestHandleChatSend_WithReplyTo(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "reply-user") - chID := seedTestChannel(t, database, "reply-chan") - send := make(chan []byte, 32) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Send first message to get an ID. - raw1, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "id": "req-1", - "payload": map[string]any{ - "channel_id": chID, - "content": "original message", - }, - }) - hub.HandleMessageForTest(c, raw1) - time.Sleep(50 * time.Millisecond) - - // Drain to find the message ID from chat_send_ok. - var msgID float64 - timeout := time.After(500 * time.Millisecond) -drainFirst: - for { - select { - case msg := <-send: - var env map[string]any - if err := json.Unmarshal(msg, &env); err == nil { - if env["type"] == "chat_send_ok" { - if p, ok := env["payload"].(map[string]any); ok { - msgID = p["message_id"].(float64) - } - break drainFirst - } - } - case <-timeout: - t.Fatal("did not receive chat_send_ok for first message") - } - } - - // Drain remaining messages. - drainChanBuf(send) - - // Send reply. - replyTo := int64(msgID) - raw2, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "id": "req-2", - "payload": map[string]any{ - "channel_id": chID, - "content": "reply message", - "reply_to": replyTo, - }, - }) - hub.HandleMessageForTest(c, raw2) - time.Sleep(50 * time.Millisecond) - - // Should get chat_send_ok for the reply. - found := false - timeout2 := time.After(500 * time.Millisecond) -drainReply: - for { - select { - case msg := <-send: - var env map[string]any - if err := json.Unmarshal(msg, &env); err == nil { - if env["type"] == "chat_send_ok" && env["id"] == "req-2" { - found = true - break drainReply - } - } - case <-timeout2: - break drainReply - } - } - if !found { - t.Error("expected chat_send_ok for reply message") - } -} - -// ─── Ping message type (handlers.go — pong response) ───────────────────────── - -func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "ping-user") - send := make(chan []byte, 4) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{"type": "ping"}) - hub.HandleMessageForTest(c, raw) - time.Sleep(20 * time.Millisecond) - - select { - case msg := <-send: - var env map[string]any - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if env["type"] != "pong" { - t.Errorf("type = %q, want pong", env["type"]) - } - case <-time.After(500 * time.Millisecond): - t.Error("expected pong response") - } -} - -// ─── buildReady with voice channel having participants ──────────────────────── - -func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "ready-voice-user") - role, rErr := database.GetRoleByID(context.Background(), 1) - if rErr != nil || role == nil { - t.Fatalf("GetRoleByID: %v", rErr) - } - - // Create a voice channel. - vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel voice: %v", err) - } - - // Create another user and join them to voice. - other := seedCoverageOwner(t, database, "ready-voice-other") - if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) - if err != nil { - t.Fatalf("BuildReadyWithRoleForTest: %v", err) - } - - var env struct { - Payload struct { - VoiceStates []struct { - ChannelID float64 `json:"channel_id"` - UserID float64 `json:"user_id"` - } `json:"voice_states"` - } `json:"payload"` - } - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(env.Payload.VoiceStates) != 1 { - t.Errorf("voice_states count = %d, want 1", len(env.Payload.VoiceStates)) - } -} - -func TestBuildReady_MultipleChannelTypes(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "ready-multi-user") - role, rErr := database.GetRoleByID(context.Background(), 1) - if rErr != nil || role == nil { - t.Fatalf("GetRoleByID: %v", rErr) - } - - // Create text and voice channels. - _, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0) - if err != nil { - t.Fatalf("CreateChannel text: %v", err) - } - _, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1) - if err != nil { - t.Fatalf("CreateChannel voice: %v", err) - } - - msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) - if err != nil { - t.Fatalf("BuildReadyWithRoleForTest: %v", err) - } - - var env struct { - Payload struct { - Channels []map[string]any `json:"channels"` - } `json:"payload"` - } - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(env.Payload.Channels) != 2 { - t.Errorf("channels count = %d, want 2", len(env.Payload.Channels)) - } - - // Text channels should have unread_count; voice channels should not. - for _, ch := range env.Payload.Channels { - if ch["type"] == "text" { - if _, ok := ch["unread_count"]; !ok { - t.Error("text channel missing unread_count") - } - } - } -} - -// ─── voice handler edge cases ──────────────────────────────────────────────── - -func TestHandleVoiceJoin_InvalidChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-bad-chid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": "not-a-number", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleVoiceJoin_NegativeChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-neg-chid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": -1, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleVoiceMute_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vm-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Put client in voice so the "not in voice" guard doesn't fire first. - ws.SetClientVoiceChID(c, 999) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_mute", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_mute payload", code) - } -} - -func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vd-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Put client in voice so the "not in voice" guard doesn't fire first. - ws.SetClientVoiceChID(c, 999) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_deafen", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_deafen payload", code) - } -} - -// ─── voice camera and screenshare error paths ──────────────────────────────── - -func TestHandleVoiceCamera_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vc-not-in-voice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_camera", - "payload": map[string]any{ - "enabled": true, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "VOICE_ERROR" { - t.Errorf("error code = %q, want VOICE_ERROR", code) - } -} - -func TestHandleVoiceCamera_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vc-bad-payload") - vcID, err := database.CreateChannel(context.Background(), "cam-vc", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Set voice channel so the not-in-voice check passes. - ws.SetClientVoiceChID(c, vcID) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_camera", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vs-not-in-voice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_screenshare", - "payload": map[string]any{ - "enabled": true, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "VOICE_ERROR" { - t.Errorf("error code = %q, want VOICE_ERROR", code) - } -} - -func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vs-bad-payload") - vcID, err := database.CreateChannel(context.Background(), "screen-vc", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - ws.SetClientVoiceChID(c, vcID) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_screenshare", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -// ─── channel_focus handler ─────────────────────────────────────────────────── - -func TestHandleChannelFocus_InvalidChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "cf-bad-chid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "channel_focus", - "payload": map[string]any{ - "channel_id": "not-a-number", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(20 * time.Millisecond) - - // V2 CommandConstructor rejects non-numeric channel_id with BAD_REQUEST. - code := drainForErrorCode(send, 100*time.Millisecond) - if code != "BAD_REQUEST" { - t.Fatalf("expected BAD_REQUEST for non-numeric channel_id, got code=%q", code) - } -} - -func TestHandleChannelFocus_ValidChannel(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "cf-valid") - chID := seedTestChannel(t, database, "cf-valid-chan") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "channel_focus", - "payload": map[string]any{ - "channel_id": chID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(20 * time.Millisecond) - - // Valid channel focus should not produce an error message. - code := drainForErrorCode(send, 100*time.Millisecond) - if code != "" { - t.Errorf("expected no error for valid channel_focus, got code=%q", code) - } -} - -// ─── presence handler error paths ──────────────────────────────────────────── - -func TestHandlePresence_InvalidStatus(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "pres-bad-status") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "presence_update", - "payload": map[string]any{ - "status": "invisible", // not allowed per CLAUDE.md - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid status", code) - } -} - -func TestHandlePresence_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "pres-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "presence_update", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid presence payload", code) - } -} - -// ─── typing handler error path ─────────────────────────────────────────────── - -func TestHandleTyping_InvalidChannelID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "typing-bad-chid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "typing_start", - "payload": map[string]any{ - "channel_id": -1, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for invalid typing channel_id", code) - } -} - -// ─── message builder coverage ──────────────────────────────────────────────── - -func TestBuildPresenceMsg_ValidJSON(t *testing.T) { - msg := ws.BuildJSONForTest(map[string]any{ - "type": "presence", - "payload": map[string]any{ - "user_id": 1, - "status": "online", - }, - }) - if !json.Valid(msg) { - t.Error("buildPresenceMsg output is not valid JSON") - } -} - -func TestBuildChatSendOK_ValidJSON(t *testing.T) { - msg := ws.BuildJSONForTest(map[string]any{ - "type": "chat_send_ok", - "id": "req-1", - "payload": map[string]any{ - "message_id": 1, - "timestamp": "2024-01-01T00:00:00Z", - }, - }) - if !json.Valid(msg) { - t.Error("buildChatSendOK output is not valid JSON") - } -} - -// ─── SendToUser full buffer path (hub.go:308 — 87.5%) ─────────────────────── - -func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "send-full-user") - // Create a send channel with buffer size 1. - send := make(chan []byte, 1) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Fill the buffer. - send <- []byte(`{"type":"filler"}`) - - // Next send should return false (buffer full). - ok := hub.SendToUser(user.ID, []byte(`{"type":"overflow"}`)) - if ok { - t.Error("SendToUser should return false when send buffer is full") - } -} - -// ─── handleChatSend with attachments (handlers.go:127 — 76.2%) ────────────── - -func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { - hub, database := newCoverageHub(t) - // Use a member user. - _, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4) - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user") - if err != nil || user == nil { - t.Fatalf("GetUserByUsername: %v", err) - } - - chID := seedTestChannel(t, database, "attach-noperm-chan") - - // Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4). - _, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) - if err != nil { - t.Fatalf("INSERT channel_overrides: %v", err) - } - - send := make(chan []byte, 32) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": "msg with attachment", - "attachments": []string{"att-id-1"}, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "FORBIDDEN" { - t.Errorf("error code = %q, want FORBIDDEN for denied ATTACH_FILES permission", code) - } -} - -func TestHandleChatSend_WithAttachments_Success(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "attach-ok-user") - chID := seedTestChannel(t, database, "attach-ok-chan") - send := make(chan []byte, 32) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "id": "attach-req", - "payload": map[string]any{ - "channel_id": chID, - "content": "msg with attachment", - "attachments": []string{"nonexistent-att-id"}, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - // Should still succeed (attachments that don't exist are silently skipped). - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { - found = true - break - } - } - if !found { - t.Error("expected chat_send_ok even with nonexistent attachment IDs") - } -} - -// ─── handleChatSend slow mode for non-mod user (handlers.go:164) ──────────── - -func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) { - hub, database := newCoverageHub(t) - _, err := database.CreateUser(context.Background(), "slow-member-user", "hash", 4) - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - user, err := database.GetUserByUsername(context.Background(), "slow-member-user") - if err != nil || user == nil { - t.Fatalf("GetUserByUsername: %v", err) - } - - // Create channel with slow mode. - chID, err := database.CreateChannel(context.Background(), "slow-chan", "text", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - if err := database.SetChannelSlowMode(context.Background(), chID, 60); err != nil { - t.Fatalf("SetChannelSlowMode: %v", err) - } - - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": "first message", - }, - }) - - // First message should succeed. - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - drainChanBuf(send) - - // Second message should be rate limited by slow mode. - raw2, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": "second message", - }, - }) - hub.HandleMessageForTest(c, raw2) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "SLOW_MODE" { - t.Errorf("error code = %q, want SLOW_MODE", code) - } -} - -// ─── handleChatEdit more paths (handlers.go:249 — 89.7%) ──────────────────── - -func TestHandleChatEdit_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "edit-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_edit", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleChatEdit_InvalidMessageID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "edit-bad-msgid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_edit", - "payload": map[string]any{ - "message_id": -1, - "content": "updated", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleChatEdit_EmptyContent(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "edit-empty") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_edit", - "payload": map[string]any{ - "message_id": 1, - "content": "", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -// ─── handleChatDelete more paths (handlers.go:298) ─────────────────────────── - -func TestHandleChatDelete_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "delete-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_delete", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleChatDelete_InvalidMessageID(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "delete-bad-msgid") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_delete", - "payload": map[string]any{ - "message_id": -1, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleChatDelete_MessageNotFound(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "delete-notfound") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_delete", - "payload": map[string]any{ - "message_id": 99999, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - // Handler returns FORBIDDEN (not NOT_FOUND) to prevent message-ID enumeration. - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "FORBIDDEN" { - t.Errorf("error code = %q, want FORBIDDEN", code) - } -} - -// ─── handleReaction more paths (handlers.go:337) ───────────────────────────── - -func TestHandleReaction_InvalidPayload(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "react-bad-payload") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "reaction_add", - "payload": "not-an-object", - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleReaction_EmptyEmoji(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "react-empty-emoji") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "reaction_add", - "payload": map[string]any{ - "message_id": 1, - "emoji": "", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleReaction_EmojiTooLong(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "react-long-emoji") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "reaction_add", - "payload": map[string]any{ - "message_id": 1, - "emoji": strings.Repeat("x", 33), - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleReaction_ControlCharInEmoji(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "react-ctrl-emoji") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "reaction_add", - "payload": map[string]any{ - "message_id": 1, - "emoji": "\x00bad", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST for control char emoji", code) - } -} - -// ─── handleChannelFocus with message marking (handlers.go:507) ─────────────── - -func TestHandleChannelFocus_UpdatesReadState(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "cf-readstate-user") - chID := seedTestChannel(t, database, "cf-readstate-chan") - - // Insert a message so there's a latest_message_id. - _, err := database.CreateMessage(context.Background(), chID, user.ID, "test message", nil) - if err != nil { - t.Fatalf("CreateMessage: %v", err) - } - - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "channel_focus", - "payload": map[string]any{ - "channel_id": chID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - // No error should be sent for a valid channel_focus with existing message. - code := drainForErrorCode(send, 100*time.Millisecond) - if code != "" { - t.Fatalf("expected no error for valid channel_focus, got code=%q", code) - } -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// drainForErrorCode reads from ch until an error message is found or deadline passes. -func drainForErrorCode(ch <-chan []byte, deadline time.Duration) string { - timer := time.NewTimer(deadline) - defer timer.Stop() - for { - select { - case msg := <-ch: - var env map[string]any - if err := json.Unmarshal(msg, &env); err != nil { - continue - } - if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]any); ok { - code, _ := payload["code"].(string) - return code - } - } - case <-timer.C: - return "" - } - } -} - -// drainChanBuf drains all buffered messages from a channel. -func drainChanBuf(ch <-chan []byte) { - for { - select { - case <-ch: - default: - return - } - } -} - -// drainChanTimeout reads messages until timeout, returning all collected. -func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte { - var msgs [][]byte - timer := time.NewTimer(d) - defer timer.Stop() - for { - select { - case msg := <-ch: - msgs = append(msgs, msg) - case <-timer.C: - return msgs - } - } -} - -// ─── voice join/leave full flow (voice_handlers.go coverage) ───────────────── - -func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { - t.Helper() - id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel voice: %v", err) - } - return id -} - -func TestHandleVoiceJoin_FullFlow(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-flow-user") - vcID := seedVoiceChannel(t, database, "vj-flow-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 500*time.Millisecond) - foundState := false - foundConfig := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil { - switch env["type"] { - case "voice_state": - foundState = true - case "voice_config": - foundConfig = true - } - } - } - if !foundState { - t.Error("expected voice_state broadcast after voice_join") - } - if !foundConfig { - t.Error("expected voice_config after voice_join") - } -} - -// TestVoiceState_NotDeliveredToRolesDeniedRead locks the visibility invariant -// for voice metadata: voice_state / voice_leave used to go out via -// BroadcastToAll, so every authenticated client learned the membership and -// camera/mute state of voice channels that channel_overrides hides from their -// role — even though the ready payload deliberately filters them out. A member -// who can read the channel must still receive them. -func TestVoiceState_NotDeliveredToRolesDeniedRead(t *testing.T) { - hub, database := newCoverageHub(t) - joiner := seedCoverageOwner(t, database, "vs-joiner") - vcID := seedVoiceChannel(t, database, "vs-private-vc") - - // Two plain members (role 4). One is locked out of the channel with the - // override the admin panel writes when "Can access" is unchecked. - newMember := func(name string) *db.User { - t.Helper() - if _, err := database.CreateUser(context.Background(), name, "hash", 4); err != nil { - t.Fatalf("CreateUser %s: %v", name, err) - } - u, err := database.GetUserByUsername(context.Background(), name) - if err != nil || u == nil { - t.Fatalf("GetUserByUsername %s: %v", name, err) - } - return u - } - insider := newMember("vs-insider") - outsiderRole := int64(3) // Moderator: a distinct role so the deny is role-scoped - outsider := newMember("vs-outsider") - if _, err := database.ExecContext(context.Background(), - `UPDATE users SET role_id = ? WHERE id = ?`, outsiderRole, outsider.ID, - ); err != nil { - t.Fatalf("reassign outsider role: %v", err) - } - if err := database.UpsertChannelOverride(context.Background(), vcID, outsiderRole, 0, - permissions.ReadMessages|permissions.ConnectVoice); err != nil { - t.Fatalf("UpsertChannelOverride: %v", err) - } - - insiderSend := make(chan []byte, 64) - outsiderSend := make(chan []byte, 64) - hub.Register(ws.NewTestClientWithUser(hub, insider, 0, insiderSend)) - hub.Register(ws.NewTestClientWithUser(hub, outsider, 0, outsiderSend)) - - joinerSend := make(chan []byte, 64) - jc := ws.NewTestClientWithUser(hub, joiner, 0, joinerSend) - hub.Register(jc) - time.Sleep(30 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{"channel_id": vcID}, - }) - hub.HandleMessageForTest(jc, raw) - time.Sleep(150 * time.Millisecond) - - countVoiceState := func(ch <-chan []byte) int { - n := 0 - for _, msg := range drainChanTimeout(ch, 300*time.Millisecond) { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - n++ - } - } - return n - } - if got := countVoiceState(insiderSend); got == 0 { - t.Error("a member who may READ the channel must still receive voice_state") - } - if got := countVoiceState(outsiderSend); got != 0 { - t.Errorf("a role denied READ received %d voice_state events, want 0", got) - } -} - -func TestHandleVoiceJoin_AlreadyInSameChannel(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-same-user") - vcID := seedVoiceChannel(t, database, "vj-same-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "ALREADY_JOINED" { - t.Errorf("error code = %q, want ALREADY_JOINED", code) - } -} - -func TestHandleVoiceJoin_SwitchChannels(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-switch-user") - vc1 := seedVoiceChannel(t, database, "vj-switch-vc1") - vc2 := seedVoiceChannel(t, database, "vj-switch-vc2") - send := make(chan []byte, 128) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw1, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vc1, - }, - }) - hub.HandleMessageForTest(c, raw1) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - raw2, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vc2, - }, - }) - hub.HandleMessageForTest(c, raw2) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - foundLeave := false - foundConfig := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil { - switch env["type"] { - case "voice_leave": - foundLeave = true - case "voice_config": - foundConfig = true - } - } - } - if !foundLeave { - t.Error("expected voice_leave broadcast when switching channels") - } - if !foundConfig { - t.Error("expected voice_config for new channel") - } -} - -func TestHandleVoiceJoin_ChannelFull(t *testing.T) { - hub, database := newCoverageHub(t) - vcID, err := database.CreateChannel(context.Background(), "full-vc", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) - if err != nil { - t.Fatalf("UPDATE channels: %v", err) - } - - user1 := seedCoverageOwner(t, database, "vj-full-u1") - send1 := make(chan []byte, 64) - c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) - hub.Register(c1) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c1, raw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send1) - - user2 := seedCoverageOwner(t, database, "vj-full-u2") - send2 := make(chan []byte, 64) - c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) - hub.Register(c2) - time.Sleep(20 * time.Millisecond) - - hub.HandleMessageForTest(c2, raw) - time.Sleep(100 * time.Millisecond) - - code := drainForErrorCode(send2, 300*time.Millisecond) - if code != "CHANNEL_FULL" { - t.Errorf("error code = %q, want CHANNEL_FULL", code) - } -} - -func TestHandleVoiceLeave_ExplicitLeave(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vl-explicit-user") - vcID := seedVoiceChannel(t, database, "vl-explicit-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) - hub.HandleMessageForTest(c, leaveRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - foundLeave := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { - foundLeave = true - break - } - } - if !foundLeave { - t.Error("expected voice_leave broadcast after explicit leave") - } -} - -func TestHandleVoiceLeave_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vl-not-in-voice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - hub.HandleVoiceLeaveForTest(c) - time.Sleep(20 * time.Millisecond) - - // Client should still be connected and have no voice channel set. - if !hub.IsUserConnected(user.ID) { - t.Error("user should still be connected after no-op voice leave") - } - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Errorf("voiceChID = %d, want 0 after leave when not in voice", got) - } -} - -func TestHandleVoiceMute_FullFlow(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vm-flow-user") - vcID := seedVoiceChannel(t, database, "vm-flow-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - muteRaw, _ := json.Marshal(map[string]any{ - "type": "voice_mute", - "payload": map[string]any{ - "muted": true, - }, - }) - hub.HandleMessageForTest(c, muteRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - found = true - break - } - } - if !found { - t.Error("expected voice_state broadcast after mute") - } -} - -func TestHandleVoiceDeafen_FullFlow(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vd-flow-user") - vcID := seedVoiceChannel(t, database, "vd-flow-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - deafenRaw, _ := json.Marshal(map[string]any{ - "type": "voice_deafen", - "payload": map[string]any{ - "deafened": true, - }, - }) - hub.HandleMessageForTest(c, deafenRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - found = true - break - } - } - if !found { - t.Error("expected voice_state broadcast after deafen") - } -} - -func TestHandleVoiceJoin_ChannelNotFound(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-notfound-user") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": 99999, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "NOT_FOUND" { - t.Errorf("error code = %q, want NOT_FOUND", code) - } -} - -func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-quality-user") - - vcID, err := database.CreateChannel(context.Background(), "quality-vc", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) - if err != nil { - t.Fatalf("UPDATE: %v", err) - } - - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { - p := env["payload"].(map[string]any) - if p["quality"] != "high" { - t.Errorf("voice_config quality = %v, want high", p["quality"]) - } - return - } - } - t.Error("expected voice_config with quality override") -} - -func TestHandleVoiceJoin_MultipleParticipants(t *testing.T) { - hub, database := newCoverageHub(t) - vcID := seedVoiceChannel(t, database, "vj-multi-vc") - - user1 := seedCoverageOwner(t, database, "vj-multi-u1") - send1 := make(chan []byte, 64) - c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) - hub.Register(c1) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c1, raw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send1) - - user2 := seedCoverageOwner(t, database, "vj-multi-u2") - send2 := make(chan []byte, 64) - c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) - hub.Register(c2) - time.Sleep(20 * time.Millisecond) - - hub.HandleMessageForTest(c2, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send2, 300*time.Millisecond) - voiceStateCount := 0 - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - voiceStateCount++ - } - } - if voiceStateCount < 2 { - t.Errorf("voice_state count = %d, want at least 2", voiceStateCount) - } -} - -func TestHandleVoiceLeave_BroadcastsToOtherParticipants(t *testing.T) { - hub, database := newCoverageHub(t) - vcID := seedVoiceChannel(t, database, "vl-bcast-vc") - - user1 := seedCoverageOwner(t, database, "vl-bcast-u1") - user2 := seedCoverageOwner(t, database, "vl-bcast-u2") - send1 := make(chan []byte, 64) - send2 := make(chan []byte, 64) - c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) - c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) - hub.Register(c1) - hub.Register(c2) - time.Sleep(30 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c1, joinRaw) - time.Sleep(100 * time.Millisecond) - hub.HandleMessageForTest(c2, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send1) - drainChanBuf(send2) - - leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) - hub.HandleMessageForTest(c1, leaveRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send2, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { - found = true - break - } - } - if !found { - t.Error("user2 should receive voice_leave when user1 leaves") - } -} - -func TestHandleVoiceCamera_FullFlow(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vc-flow-user") - vcID := seedVoiceChannel(t, database, "vc-flow-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - camRaw, _ := json.Marshal(map[string]any{ - "type": "voice_camera", - "payload": map[string]any{ - "enabled": true, - }, - }) - hub.HandleMessageForTest(c, camRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - found = true - break - } - } - if !found { - t.Error("expected voice_state after camera toggle") - } -} - -func TestHandleVoiceScreenshare_FullFlow(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vs-flow-user") - vcID := seedVoiceChannel(t, database, "vs-flow-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - joinRaw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, joinRaw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - ssRaw, _ := json.Marshal(map[string]any{ - "type": "voice_screenshare", - "payload": map[string]any{ - "enabled": true, - }, - }) - hub.HandleMessageForTest(c, ssRaw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { - found = true - break - } - } - if !found { - t.Error("expected voice_state after screenshare toggle") - } -} - -func TestHandleChatSend_WithNilAvatar(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "nil-avatar-user") - chID := seedTestChannel(t, database, "nil-avatar-chan") - send := make(chan []byte, 32) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "id": "avatar-req", - "payload": map[string]any{ - "channel_id": chID, - "content": "hello from nil avatar user", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - found := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { - found = true - break - } - } - if !found { - t.Error("expected chat_send_ok for nil-avatar user") - } -} - -// ─── hasChannelPerm with nil user (handlers.go:454) ────────────────────────── - -func TestHasChannelPerm_NilUser_DeniesPermission(t *testing.T) { - hub, database := newCoverageHub(t) - chID := seedTestChannel(t, database, "perm-nil-user-chan") - send := make(chan []byte, 16) - // Create a test client WITHOUT a user (user == nil). - c := ws.NewTestClient(hub, 1, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Try to send a chat message — should get FORBIDDEN due to nil user. - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "payload": map[string]any{ - "channel_id": chID, - "content": "should fail", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "FORBIDDEN" { - t.Errorf("error code = %q, want FORBIDDEN for nil user", code) - } -} - -// ─── deliverBroadcast with full send buffer (hub.go:344) ───────────────────── - -func TestDeliverBroadcast_FullBuffer_DropsMessage(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bcast-full-user") - // Create a tiny send buffer. - send := make(chan []byte, 1) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Fill the buffer. - send <- []byte(`{"type":"filler"}`) - - // Broadcasting should not block — message dropped. - hub.BroadcastToAll([]byte(`{"type":"should_be_dropped"}`)) - time.Sleep(50 * time.Millisecond) - - // Buffer should still contain only the filler message (dropped msg was not enqueued). - if len(send) != 1 { - t.Errorf("send buffer length = %d, want 1 (dropped message should not be enqueued)", len(send)) - } - // The client should still be registered despite the dropped message. - if !hub.IsUserConnected(user.ID) { - t.Error("client should remain connected after a dropped broadcast") - } - _ = c // keep c referenced -} - -func TestBuildAuthOK_NonNilAvatar(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "authok-avatar-user") - // Set a non-nil avatar. - _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) - if err != nil { - t.Fatalf("UPDATE avatar: %v", err) - } - user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user") - if err != nil || user == nil { - t.Fatalf("GetUserByUsername: %v", err) - } - - msg := hub.BuildAuthOKForTest(user, "owner") - var env struct { - Payload struct { - User struct { - Avatar string `json:"avatar"` - } `json:"user"` - } `json:"payload"` - } - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if env.Payload.User.Avatar != "https://example.com/pic.png" { - t.Errorf("avatar = %q, want https://example.com/pic.png", env.Payload.User.Avatar) - } -} - -func TestHandleChatSend_WithNonNilAvatar(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "avatar-user") - // Set a non-nil avatar on the user. - _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) - if err != nil { - t.Fatalf("UPDATE avatar: %v", err) - } - // Reload user to get updated avatar. - user, err = database.GetUserByUsername(context.Background(), "avatar-user") - if err != nil || user == nil { - t.Fatalf("GetUserByUsername: %v", err) - } - - chID := seedTestChannel(t, database, "avatar-chan") - send := make(chan []byte, 32) - c := ws.NewTestClientWithUser(hub, user, chID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "chat_send", - "id": "avatar-req2", - "payload": map[string]any{ - "channel_id": chID, - "content": "hello from avatar user", - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - foundOK := false - foundBroadcast := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil { - if env["type"] == "chat_send_ok" { - foundOK = true - } - if env["type"] == "chat_message" { - // Verify avatar is present in broadcast. - if p, ok := env["payload"].(map[string]any); ok { - if u, ok := p["user"].(map[string]any); ok { - if u["avatar"] == "https://example.com/avatar.png" { - foundBroadcast = true - } - } - } - } - } - } - if !foundOK { - t.Error("expected chat_send_ok for avatar user") - } - if !foundBroadcast { - t.Error("expected chat_message with non-nil avatar") - } -} - -// ─── Webhook parse helpers ────────────────────────────────────────────────── - -func TestWebhookParseIdentity_Valid(t *testing.T) { - id, err := ws.ParseIdentityForTest("user-42") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != 42 { - t.Errorf("id = %d, want 42", id) - } -} - -func TestWebhookParseIdentity_Invalid(t *testing.T) { - _, err := ws.ParseIdentityForTest("invalid") - if err == nil { - t.Fatal("expected error for invalid identity, got nil") - } -} - -func TestWebhookParseRoomChannelID_Valid(t *testing.T) { - id, err := ws.ParseRoomChannelIDForTest("channel-5") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != 5 { - t.Errorf("id = %d, want 5", id) - } -} - -func TestWebhookParseRoomChannelID_Invalid(t *testing.T) { - _, err := ws.ParseRoomChannelIDForTest("bad") - if err == nil { - t.Fatal("expected error for invalid room name, got nil") - } -} - -// ─── Voice control "not in voice" guards ──────────────────────────────────── - -func TestHandleVoiceMute_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vm-not-in-voice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_mute", - "payload": map[string]any{ - "muted": true, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "VOICE_ERROR" { - t.Errorf("error code = %q, want VOICE_ERROR", code) - } -} - -func TestHandleVoiceDeafen_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vd-not-in-voice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_deafen", - "payload": map[string]any{ - "deafened": true, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "VOICE_ERROR" { - t.Errorf("error code = %q, want VOICE_ERROR", code) - } -} - -// ─── Voice join with invalid quality fallback ─────────────────────────────── - -func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vj-badquality-user") - - vcID, err := database.CreateChannel(context.Background(), "badquality-vc", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) - if err != nil { - t.Fatalf("UPDATE: %v", err) - } - - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{ - "channel_id": vcID, - }, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { - p := env["payload"].(map[string]any) - if p["quality"] != "medium" { - t.Errorf("voice_config quality = %v, want medium", p["quality"]) - } - return - } - } - t.Error("expected voice_config with medium quality fallback") -} - -// ─── getLastActivity (client.go:153) ───────────────────────────────────────── - -func TestGetLastActivity_ReturnsZeroForNewTestClient(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - got := ws.GetLastActivityForTest(c) - if !got.IsZero() { - t.Fatalf("expected zero time for new test client, got %v", got) - } -} - -func TestGetLastActivity_UpdatedByTouch(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - before := time.Now() - ws.TouchForTest(c) - after := time.Now() - - got := ws.GetLastActivityForTest(c) - if got.Before(before) || got.After(after) { - t.Fatalf("lastActivity = %v, expected between %v and %v", got, before, after) - } -} - -func TestGetLastActivity_MultipleTouch(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.TouchForTest(c) - first := ws.GetLastActivityForTest(c) - - time.Sleep(5 * time.Millisecond) - ws.TouchForTest(c) - second := ws.GetLastActivityForTest(c) - - if !second.After(first) { - t.Fatalf("second touch (%v) should be after first (%v)", second, first) - } -} - -// ─── clearVoiceChID (client.go:203) ───────────────────────────────────────── - -func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 42) - old := ws.ClearVoiceChIDForTest(c) - if old != 42 { - t.Fatalf("clearVoiceChID returned %d, want 42", old) - } - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID after clear = %d, want 0", got) - } -} - -func TestClearVoiceChID_ReturnsZeroWhenNotInVoice(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - old := ws.ClearVoiceChIDForTest(c) - if old != 0 { - t.Fatalf("clearVoiceChID returned %d, want 0", old) - } -} - -func TestClearVoiceChID_DoubleClearReturnsZero(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 99) - first := ws.ClearVoiceChIDForTest(c) - second := ws.ClearVoiceChIDForTest(c) - if first != 99 { - t.Fatalf("first clear = %d, want 99", first) - } - if second != 0 { - t.Fatalf("second clear = %d, want 0", second) - } -} - -// ─── voice_token_refresh (now V2 — dispatched via handleMessage) ──────────── - -func voiceTokenRefreshMsg() []byte { - raw, _ := json.Marshal(map[string]any{ - "type": "voice_token_refresh", - "payload": map[string]any{}, - }) - return raw -} - -func TestHandleVoiceTokenRefresh_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vtr-notinvoice") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "BAD_REQUEST" { - t.Errorf("error code = %q, want BAD_REQUEST", code) - } -} - -func TestHandleVoiceTokenRefresh_InVoice_ReturnsToken(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "vtr-invc") - vcID := seedVoiceChannel(t, database, "vtr-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - raw, _ := json.Marshal(map[string]any{ - "type": "voice_join", - "payload": map[string]any{"channel_id": vcID}, - }) - hub.HandleMessageForTest(c, raw) - time.Sleep(100 * time.Millisecond) - drainChanBuf(send) - - hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) - time.Sleep(100 * time.Millisecond) - - msgs := drainChanTimeout(send, 300*time.Millisecond) - foundToken := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_token" { - foundToken = true - break - } - } - if !foundToken { - t.Error("expected voice_token message after token refresh") - } -} - -func TestHandleVoiceTokenRefresh_NilUser(t *testing.T) { - hub, database := newCoverageHub(t) - // The client deliberately carries no *db.User — that is what this test - // covers — but the row must exist so the CONNECT_VOICE re-check can resolve - // a role. Without it the handler stops at FORBIDDEN and never reaches the - // missing-voice-state branch under test. - user := seedCoverageOwner(t, database, "vtr-nil-user") - send := make(chan []byte, 16) - c := ws.NewTestClient(hub, user.ID, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - ws.SetVoiceChIDForTest(c, 42) - - hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) - time.Sleep(50 * time.Millisecond) - - code := drainForErrorCode(send, 200*time.Millisecond) - if code != "INTERNAL" { - t.Errorf("error code = %q, want INTERNAL", code) - } -} - -// ─── rollbackVoiceJoin (voice_join.go:239) ────────────────────────────────── - -func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "rb-user") - vcID := seedVoiceChannel(t, database, "rb-vc") - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - ws.SetVoiceChIDForTest(c, vcID) - - hub.RollbackVoiceJoinForTest(c, vcID) - time.Sleep(100 * time.Millisecond) - - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID after rollback = %d, want 0", got) - } - - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state != nil { - t.Fatal("voice state should be nil after rollback") - } - - msgs := drainChanTimeout(send, 300*time.Millisecond) - foundLeave := false - for _, msg := range msgs { - var env map[string]any - if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { - foundLeave = true - break - } - } - if !foundLeave { - t.Error("expected voice_leave broadcast after rollback") - } -} - -func TestRollbackVoiceJoin_NoDBState_DoesNotPanic(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "rb-nostate") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - ws.SetVoiceChIDForTest(c, 999) - hub.RollbackVoiceJoinForTest(c, 999) - time.Sleep(50 * time.Millisecond) - - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID after rollback = %d, want 0", got) - } -} - -// ─── leaveVoiceChannelWithRetry (voice_leave.go:57) ───────────────────────── - -func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "lvcr-ok") - vcID := seedVoiceChannel(t, database, "lvcr-ok-vc") - - if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state == nil { - t.Fatal("voice state should exist before leave") - } - - err := ws.LeaveVoiceChannelWithRetryForTest(hub, user.ID, vcID, state.JoinedAt) - if err != nil { - t.Fatalf("leaveVoiceChannelWithRetry returned error: %v", err) - } - - state, _ = database.GetVoiceState(context.Background(), user.ID) - if state != nil { - t.Fatal("voice state should be nil after successful leave") - } -} - -func TestLeaveVoiceChannelWithRetry_NoVoiceState_NilReturn(t *testing.T) { - hub, database := newCoverageHub(t) - _ = seedCoverageOwner(t, database, "lvcr-nostate") - - err := ws.LeaveVoiceChannelWithRetryForTest(hub, 9999, 1, "") - if err != nil { - t.Fatalf("expected nil error for non-existent voice state, got: %v", err) - } -} - -// ─── CleanupVoiceForChannel (hub.go:237) — additional paths ───────────────── - -func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { - hub, database := newCoverageHub(t) - user1 := seedCoverageOwner(t, database, "cvfc-u1") - user2 := seedCoverageOwner(t, database, "cvfc-u2") - vcID := seedVoiceChannel(t, database, "cvfc-vc") - - send1 := make(chan []byte, 64) - send2 := make(chan []byte, 64) - c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) - c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) - hub.Register(c1) - hub.Register(c2) - time.Sleep(20 * time.Millisecond) - - if err := database.JoinVoiceChannel(context.Background(), user1.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel u1: %v", err) - } - if err := database.JoinVoiceChannel(context.Background(), user2.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel u2: %v", err) - } - ws.SetVoiceChIDForTest(c1, vcID) - ws.SetVoiceChIDForTest(c2, vcID) - - hub.CleanupVoiceForChannel(vcID) - time.Sleep(100 * time.Millisecond) - - if got := ws.GetClientVoiceChIDForTest(c1); got != 0 { - t.Errorf("c1 voiceChID = %d, want 0", got) - } - if got := ws.GetClientVoiceChIDForTest(c2); got != 0 { - t.Errorf("c2 voiceChID = %d, want 0", got) - } - - states, _ := database.GetChannelVoiceStates(context.Background(), vcID) - if len(states) != 0 { - t.Errorf("expected 0 voice states after cleanup, got %d", len(states)) - } -} - -func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) { - hub, database := newCoverageHub(t) - vcID := seedVoiceChannel(t, database, "cvfc-empty-vc") - hub.CleanupVoiceForChannel(vcID) - time.Sleep(20 * time.Millisecond) - - // After cleanup of an empty channel, voice states should still be empty. - states, err := database.GetChannelVoiceStates(context.Background(), vcID) - if err != nil { - t.Fatalf("GetChannelVoiceStates: %v", err) - } - if len(states) != 0 { - t.Errorf("expected 0 voice states after cleaning empty channel, got %d", len(states)) - } -} - -func TestCleanupVoiceForChannel_DBStateButNoClient(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "cvfc-noclient") - vcID := seedVoiceChannel(t, database, "cvfc-noclient-vc") - - if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - hub.CleanupVoiceForChannel(vcID) - time.Sleep(50 * time.Millisecond) - - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state != nil { - t.Error("voice state should be nil after cleanup") - } -} - -// ─── sweepStaleVoiceStates (hub.go:489) ───────────────────────────────────── - -func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "sweep-ghost") - vcID := seedVoiceChannel(t, database, "sweep-ghost-vc") - - // Put user in voice in DB but don't register a client — ghost state. - if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - // Verify it exists. - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state == nil { - t.Fatal("voice state should exist before sweep") - } - - hub.SweepStaleVoiceStatesForTest() - time.Sleep(100 * time.Millisecond) - - // Ghost state should be removed. - state, _ = database.GetVoiceState(context.Background(), user.ID) - if state != nil { - t.Error("ghost voice state should be nil after sweep") - } -} - -func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "sweep-active") - vcID := seedVoiceChannel(t, database, "sweep-active-vc") - - // Register client and set voice channel. - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - ws.SetVoiceChIDForTest(c, vcID) - - hub.SweepStaleVoiceStatesForTest() - time.Sleep(100 * time.Millisecond) - - // Active client's state should be preserved. - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state == nil { - t.Error("active client's voice state should be preserved after sweep") - } -} - -func TestSweepStaleVoiceStates_NoStatesNoPanic(t *testing.T) { - hub, database := newCoverageHub(t) - hub.SweepStaleVoiceStatesForTest() - time.Sleep(50 * time.Millisecond) - - // With no voice states in the DB, sweep should leave the system clean. - // Verify by checking a known user has no voice state. - user := seedCoverageOwner(t, database, "sweep-no-states") - state, err := database.GetVoiceState(context.Background(), user.ID) - if err != nil { - t.Fatalf("GetVoiceState: %v", err) - } - if state != nil { - t.Error("expected nil voice state for user after sweep with no states") - } -} - -func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "sweep-mismatch") - vc1 := seedVoiceChannel(t, database, "sweep-mismatch-vc1") - vc2 := seedVoiceChannel(t, database, "sweep-mismatch-vc2") - - // Register client in vc1 but DB says vc2. - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - if err := database.JoinVoiceChannel(context.Background(), user.ID, vc2); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - ws.SetVoiceChIDForTest(c, vc1) // Client thinks vc1, DB says vc2 — mismatch. - - hub.SweepStaleVoiceStatesForTest() - time.Sleep(100 * time.Millisecond) - - // Mismatched state should be removed from DB. - state, _ := database.GetVoiceState(context.Background(), user.ID) - if state != nil { - t.Error("mismatched voice state should be removed after sweep") - } -} - -// TestSweepStaleVoiceStates_EvictsRevokedConnectVoice locks the revocation half -// of the voice-permission invariant: nothing in ws re-validated CONNECT_VOICE -// for a connection that stays open, so stripping the bit blocked future joins -// but left the offender in the room. The sweep must now evict them — DB row -// gone and the client's own voice state cleared. -func TestSweepStaleVoiceStates_EvictsRevokedConnectVoice(t *testing.T) { - hub, database := newCoverageHub(t) - // Member role (id 4), not Owner: admins bypass every channel check. - if _, err := database.CreateUser(context.Background(), "sweep-revoked", "hash", 4); err != nil { - t.Fatalf("CreateUser: %v", err) - } - user, err := database.GetUserByUsername(context.Background(), "sweep-revoked") - if err != nil || user == nil { - t.Fatalf("GetUserByUsername: %v", err) - } - vcID := seedVoiceChannel(t, database, "sweep-revoked-vc") - - send := make(chan []byte, 64) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - if joinErr := database.JoinVoiceChannel(context.Background(), user.ID, vcID); joinErr != nil { - t.Fatalf("JoinVoiceChannel: %v", joinErr) - } - vs, err := database.GetVoiceState(context.Background(), user.ID) - if err != nil || vs == nil { - t.Fatalf("GetVoiceState after join: %v", err) - } - ws.SetClientVoiceStateForTest(c, vcID, vs.JoinedAt) - - // Still permitted → the sweep leaves them alone. - hub.SweepStaleVoiceStatesForTest() - time.Sleep(100 * time.Millisecond) - if state, _ := database.GetVoiceState(context.Background(), user.ID); state == nil { - t.Fatal("a permitted participant must survive the sweep") - } - - // Moderator revokes CONNECT_VOICE on this channel for the Member role. - if permErr := database.UpsertChannelOverride( - context.Background(), vcID, 4, 0, permissions.ConnectVoice, - ); permErr != nil { - t.Fatalf("UpsertChannelOverride: %v", permErr) - } - - hub.SweepStaleVoiceStatesForTest() - time.Sleep(200 * time.Millisecond) - - if state, _ := database.GetVoiceState(context.Background(), user.ID); state != nil { - t.Error("revoked participant's voice state must be deleted by the sweep") - } - if chID := ws.GetClientVoiceChIDForTest(c); chID != 0 { - t.Errorf("revoked participant's client voice state must be cleared, got channel %d", chID) - } -} - -// ─── BroadcastToChannel / BroadcastToAll full-channel path ────────────────── - -func TestBroadcastToChannel_DropsWhenFull(t *testing.T) { - hub, _ := newCoverageHub(t) - // Don't start Run() — broadcast channel will fill up. - // The broadcast channel capacity is 256. - for range 260 { - hub.BroadcastToChannel(1, []byte(`{"type":"test"}`)) - } - // With no Run() loop draining, some messages are dropped. - // Hub should still be functional after overflow — verify by checking - // that a user lookup still works (hub internals not corrupted). - if hub.IsUserConnected(9999) { - t.Error("expected false for non-existent user after broadcast overflow") - } -} - -func TestBroadcastToAll_DropsWhenFull(t *testing.T) { - hub, _ := newCoverageHub(t) - for range 260 { - hub.BroadcastToAll([]byte(`{"type":"test"}`)) - } - // Hub should still be functional after overflow — verify hub state is intact. - if hub.IsUserConnected(9999) { - t.Error("expected false for non-existent user after broadcast overflow") - } -} diff --git a/Server/ws/coverage_chat_test.go b/Server/ws/coverage_chat_test.go new file mode 100644 index 00000000..e37faa50 --- /dev/null +++ b/Server/ws/coverage_chat_test.go @@ -0,0 +1,643 @@ +package ws_test + +// coverage_chat_test.go: chat send/edit/delete, reactions, slow mode, and +// read-state coverage tests (split from coverage_boost_test.go). + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/owncord/server/ws" +) + +// ─── handleChatSend additional branches (handlers.go:127 — 76.2%) ──────────── + +func TestHandleChatSend_EmptyContent(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "empty-content-user") + chID := seedTestChannel(t, database, "empty-content-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for empty content", code) + } +} + +func TestHandleChatSend_ContentTooLong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "long-content-user") + chID := seedTestChannel(t, database, "long-content-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Content over 4000 characters. + longContent := strings.Repeat("x", 4001) + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": longContent, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for content too long", code) + } +} + +func TestHandleChatSend_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bad-chid-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": "not-a-number", + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid channel_id", code) + } +} + +func TestHandleChatSend_ChannelNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "notfound-chan-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": 99999, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("error code = %q, want NOT_FOUND for nonexistent channel", code) + } +} + +func TestHandleChatSend_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bad-payload-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid payload", code) + } +} + +func TestHandleChatSend_NegativeChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "neg-chid-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": -1, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for negative channel_id", code) + } +} + +// ─── handleChatSend with reply_to (handlers.go:127 — covers reply_to path) ── + +func TestHandleChatSend_WithReplyTo(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "reply-user") + chID := seedTestChannel(t, database, "reply-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Send first message to get an ID. + raw1, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "req-1", + "payload": map[string]any{ + "channel_id": chID, + "content": "original message", + }, + }) + hub.HandleMessageForTest(c, raw1) + + // Drain to find the message ID from chat_send_ok. + var msgID float64 + timeout := time.After(500 * time.Millisecond) +drainFirst: + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "chat_send_ok" { + if p, ok := env["payload"].(map[string]any); ok { + msgID = p["message_id"].(float64) + } + break drainFirst + } + } + case <-timeout: + t.Fatal("did not receive chat_send_ok for first message") + } + } + + // Drain remaining messages. + drainChanBuf(send) + + // Send reply. + replyTo := int64(msgID) + raw2, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "req-2", + "payload": map[string]any{ + "channel_id": chID, + "content": "reply message", + "reply_to": replyTo, + }, + }) + hub.HandleMessageForTest(c, raw2) + + // Should get chat_send_ok for the reply. + found := false + timeout2 := time.After(500 * time.Millisecond) +drainReply: + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "chat_send_ok" && env["id"] == "req-2" { + found = true + break drainReply + } + } + case <-timeout2: + break drainReply + } + } + if !found { + t.Error("expected chat_send_ok for reply message") + } +} + +// ─── handleChatSend slow mode for non-mod user (handlers.go:164) ──────────── + +func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) { + hub, database := newCoverageHub(t) + _, err := database.CreateUser(context.Background(), "slow-member-user", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(context.Background(), "slow-member-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + // Create channel with slow mode. + chID, err := database.CreateChannel(context.Background(), "slow-chan", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.SetChannelSlowMode(context.Background(), chID, 60); err != nil { + t.Fatalf("SetChannelSlowMode: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "first message", + }, + }) + + // First message should succeed. + hub.HandleMessageForTest(c, raw) + drainChanTimeout(send, 50*time.Millisecond) + + // Second message should be rate limited by slow mode. + raw2, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "second message", + }, + }) + hub.HandleMessageForTest(c, raw2) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "SLOW_MODE" { + t.Errorf("error code = %q, want SLOW_MODE", code) + } +} + +// ─── handleChatEdit more paths (handlers.go:249 — 89.7%) ──────────────────── + +func TestHandleChatEdit_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatEdit_InvalidMessageID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-bad-msgid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": -1, + "content": "updated", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatEdit_EmptyContent(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-empty") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": 1, + "content": "", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +// ─── handleChatDelete more paths (handlers.go:298) ─────────────────────────── + +func TestHandleChatDelete_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatDelete_InvalidMessageID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-bad-msgid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatDelete_MessageNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-notfound") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": 99999, + }, + }) + hub.HandleMessageForTest(c, raw) + + // Handler returns FORBIDDEN (not NOT_FOUND) to prevent message-ID enumeration. + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN", code) + } +} + +// ─── handleReaction more paths (handlers.go:337) ───────────────────────────── + +func TestHandleReaction_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_EmptyEmoji(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-empty-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": "", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_EmojiTooLong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-long-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": strings.Repeat("x", 33), + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_ControlCharInEmoji(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-ctrl-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": "\x00bad", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for control char emoji", code) + } +} + +// ─── handleChannelFocus with message marking (handlers.go:507) ─────────────── + +func TestHandleChannelFocus_UpdatesReadState(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-readstate-user") + chID := seedTestChannel(t, database, "cf-readstate-chan") + + // Insert a message so there's a latest_message_id. + _, err := database.CreateMessage(context.Background(), chID, user.ID, "test message", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": chID, + }, + }) + hub.HandleMessageForTest(c, raw) + + // No error should be sent for a valid channel_focus with existing message. + code := drainForErrorCode(send, 100*time.Millisecond) + if code != "" { + t.Fatalf("expected no error for valid channel_focus, got code=%q", code) + } +} + +func TestHandleChatSend_WithNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "nil-avatar-user") + chID := seedTestChannel(t, database, "nil-avatar-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "avatar-req", + "payload": map[string]any{ + "channel_id": chID, + "content": "hello from nil avatar user", + }, + }) + hub.HandleMessageForTest(c, raw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { + found = true + break + } + } + if !found { + t.Error("expected chat_send_ok for nil-avatar user") + } +} + +func TestHandleChatSend_WithNonNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "avatar-user") + // Set a non-nil avatar on the user. + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) + if err != nil { + t.Fatalf("UPDATE avatar: %v", err) + } + // Reload user to get updated avatar. + user, err = database.GetUserByUsername(context.Background(), "avatar-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + chID := seedTestChannel(t, database, "avatar-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "avatar-req2", + "payload": map[string]any{ + "channel_id": chID, + "content": "hello from avatar user", + }, + }) + hub.HandleMessageForTest(c, raw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundOK := false + foundBroadcast := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + if env["type"] == "chat_send_ok" { + foundOK = true + } + if env["type"] == "chat_message" { + // Verify avatar is present in broadcast. + if p, ok := env["payload"].(map[string]any); ok { + if u, ok := p["user"].(map[string]any); ok { + if u["avatar"] == "https://example.com/avatar.png" { + foundBroadcast = true + } + } + } + } + } + } + if !foundOK { + t.Error("expected chat_send_ok for avatar user") + } + if !foundBroadcast { + t.Error("expected chat_message with non-nil avatar") + } +} diff --git a/Server/ws/coverage_helpers_test.go b/Server/ws/coverage_helpers_test.go new file mode 100644 index 00000000..f270f63b --- /dev/null +++ b/Server/ws/coverage_helpers_test.go @@ -0,0 +1,180 @@ +package ws_test + +// coverage_helpers_test.go holds the shared schema, hub constructors, and +// drain/seed helpers used by the coverage_* test files (split from the former +// coverage_boost_test.go, which added tests for low-coverage functions). + +import ( + "context" + "encoding/json" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/service" + "github.com/owncord/server/ws" +) + +// ─── schema with voice_states + audit_log for coverage tests ────────────────── + +var coverageSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS 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, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel_cov ON voice_states(channel_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + uploader_id INTEGER REFERENCES users(id), + 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 +); + +`)...) + +func openCoverageDB(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() }) + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: coverageSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openCoverageDB(t) + limiter := auth.NewRateLimiter() + st := database + svc := service.New(st, limiter) + hub := ws.NewHub(database, limiter, svc) + + // Inject a test LiveKit client so voice_join passes the livekit!=nil guard. + lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-12345", + LiveKitAPISecret: "test-api-secret-67890abcdef", + LiveKitURL: "ws://localhost:7880", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + hub.SetLiveKit(lk) + + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(context.Background(), username, "hash", 1) + if err != nil { + t.Fatalf("seedCoverageOwner CreateUser: %v", err) + } + user, err := database.GetUserByUsername(context.Background(), username) + if err != nil || user == nil { + t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err) + } + return user +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// drainForErrorCode reads from ch until an error message is found or deadline passes. +func drainForErrorCode(ch <-chan []byte, deadline time.Duration) string { + timer := time.NewTimer(deadline) + defer timer.Stop() + for { + select { + case msg := <-ch: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + code, _ := payload["code"].(string) + return code + } + } + case <-timer.C: + return "" + } + } +} + +// drainChanBuf drains all buffered messages from a channel. +func drainChanBuf(ch <-chan []byte) { + for { + select { + case <-ch: + default: + return + } + } +} + +// drainChanTimeout reads messages until timeout, returning all collected. +func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte { + var msgs [][]byte + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case msg := <-ch: + msgs = append(msgs, msg) + case <-timer.C: + return msgs + } + } +} + +func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + return id +} + +func voiceTokenRefreshMsg() []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_token_refresh", + "payload": map[string]any{}, + }) + return raw +} diff --git a/Server/ws/coverage_misc_test.go b/Server/ws/coverage_misc_test.go new file mode 100644 index 00000000..f6819efb --- /dev/null +++ b/Server/ws/coverage_misc_test.go @@ -0,0 +1,729 @@ +package ws_test + +// coverage_misc_test.go: client state, message builders, hub lifecycle, +// ping, buildReady, presence/focus/typing, attachments, permissions, +// broadcast, and webhook coverage tests (split from coverage_boost_test.go). + +import ( + "context" + "encoding/json" + "math" + "testing" + "time" + + "github.com/owncord/server/ws" +) + +// ─── SetClientVoiceChID stores the tracked voice channel ───────────────────── + +func TestSetClientVoiceChID_SetsValue(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetClientVoiceChID(c, 42) + if got := ws.GetClientVoiceChIDForTest(c); got != 42 { + t.Fatalf("voiceChID = %d, want 42", got) + } +} + +func TestSetClientVoiceChID_ZeroClearsVoice(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetClientVoiceChID(c, 100) + ws.SetClientVoiceChID(c, 0) + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Fatalf("voiceChID = %d, want 0", got) + } +} + +func TestSetClientVoiceChID_LastWriteWins(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetClientVoiceChID(c, 7) + ws.SetClientVoiceChID(c, 99) + if got := ws.GetClientVoiceChIDForTest(c); got != 99 { + t.Fatalf("voiceChID = %d, want 99", got) + } +} + +// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ──────────────── + +func TestBuildJSON_UnmarshalableValue_ReturnsFallback(t *testing.T) { + // math.Inf is not valid JSON — forces the error path in buildJSON. + out := ws.BuildJSONForTest(math.Inf(1)) + if !json.Valid(out) { + t.Fatalf("fallback output is not valid JSON: %s", out) + } + var m map[string]string + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal fallback: %v", err) + } + if m["type"] != "error" { + t.Errorf("fallback type = %q, want error", m["type"]) + } + if m["message"] != "internal marshal error" { + t.Errorf("fallback message = %q, want 'internal marshal error'", m["message"]) + } +} + +func TestBuildJSON_ChannelValue_ReturnsFallback(t *testing.T) { + // Channels are not JSON-marshalable. + out := ws.BuildJSONForTest(make(chan int)) + if !json.Valid(out) { + t.Fatalf("fallback output is not valid JSON: %s", out) + } +} + +// ─── GracefulStop with clients having voice state (hub.go:188 — 75%) ───────── + +func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) { + hub, database := newCoverageHub(t) + + user := seedCoverageOwner(t, database, "graceful-voice-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Set voice channel ID on the client to simulate voice state. + ws.SetClientVoiceChID(c, 42) + + if count := hub.ClientCount(); count != 1 { + t.Fatalf("before GracefulStop: client count = %d, want 1", count) + } + if got := ws.GetClientVoiceChIDForTest(c); got != 42 { + t.Fatalf("voiceChID before stop = %d, want 42", got) + } + + // GracefulStop is synchronous — returning at all proves no deadlock. + hub.GracefulStop() + // GracefulStop signals clients to close — test clients don't have real + // goroutines so they won't self-unregister, but verify the hub accepted + // the stop without deadlocking on voice-state cleanup. +} + +func TestGracefulStop_MultipleClients(t *testing.T) { + hub, database := newCoverageHub(t) + + for i := range 5 { + user := seedCoverageOwner(t, database, "graceful-multi-"+string(rune('a'+i))) + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + } + waitClientCount(t, hub, 5) + + if count := hub.ClientCount(); count != 5 { + t.Fatalf("before GracefulStop: client count = %d, want 5", count) + } + + // GracefulStop is synchronous — returning at all proves no deadlock on + // multiple clients. + hub.GracefulStop() +} + +// ─── Ping message type (handlers.go — pong response) ───────────────────────── + +func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ping-user") + send := make(chan []byte, 4) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{"type": "ping"}) + // The pong reply is sent synchronously by handleMessage. + hub.HandleMessageForTest(c, raw) + + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env["type"] != "pong" { + t.Errorf("type = %q, want pong", env["type"]) + } + case <-time.After(500 * time.Millisecond): + t.Error("expected pong response") + } +} + +// ─── buildReady with voice channel having participants ──────────────────────── + +func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ready-voice-user") + role, rErr := database.GetRoleByID(context.Background(), 1) + if rErr != nil || role == nil { + t.Fatalf("GetRoleByID: %v", rErr) + } + + // Create a voice channel. + vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + + // Create another user and join them to voice. + other := seedCoverageOwner(t, database, "ready-voice-other") + if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + + var env struct { + Payload struct { + VoiceStates []struct { + ChannelID float64 `json:"channel_id"` + UserID float64 `json:"user_id"` + } `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 1 { + t.Errorf("voice_states count = %d, want 1", len(env.Payload.VoiceStates)) + } +} + +func TestBuildReady_MultipleChannelTypes(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ready-multi-user") + role, rErr := database.GetRoleByID(context.Background(), 1) + if rErr != nil || role == nil { + t.Fatalf("GetRoleByID: %v", rErr) + } + + // Create text and voice channels. + _, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0) + if err != nil { + t.Fatalf("CreateChannel text: %v", err) + } + _, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []map[string]any `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.Channels) != 2 { + t.Errorf("channels count = %d, want 2", len(env.Payload.Channels)) + } + + // Text channels should have unread_count; voice channels should not. + for _, ch := range env.Payload.Channels { + if ch["type"] == "text" { + if _, ok := ch["unread_count"]; !ok { + t.Error("text channel missing unread_count") + } + } + } +} + +// ─── channel_focus handler ─────────────────────────────────────────────────── + +func TestHandleChannelFocus_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": "not-a-number", + }, + }) + hub.HandleMessageForTest(c, raw) + + // V2 CommandConstructor rejects non-numeric channel_id with BAD_REQUEST. + code := drainForErrorCode(send, 100*time.Millisecond) + if code != "BAD_REQUEST" { + t.Fatalf("expected BAD_REQUEST for non-numeric channel_id, got code=%q", code) + } +} + +func TestHandleChannelFocus_ValidChannel(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-valid") + chID := seedTestChannel(t, database, "cf-valid-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": chID, + }, + }) + hub.HandleMessageForTest(c, raw) + + // Valid channel focus should not produce an error message. + code := drainForErrorCode(send, 100*time.Millisecond) + if code != "" { + t.Errorf("expected no error for valid channel_focus, got code=%q", code) + } +} + +// ─── presence handler error paths ──────────────────────────────────────────── + +func TestHandlePresence_InvalidStatus(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "pres-bad-status") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": map[string]any{ + "status": "invisible", // not allowed per CLAUDE.md + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid status", code) + } +} + +func TestHandlePresence_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "pres-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid presence payload", code) + } +} + +// ─── typing handler error path ─────────────────────────────────────────────── + +func TestHandleTyping_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "typing-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "typing_start", + "payload": map[string]any{ + "channel_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid typing channel_id", code) + } +} + +// ─── message builder coverage ──────────────────────────────────────────────── + +func TestBuildPresenceMsg_ValidJSON(t *testing.T) { + msg := ws.BuildJSONForTest(map[string]any{ + "type": "presence", + "payload": map[string]any{ + "user_id": 1, + "status": "online", + }, + }) + if !json.Valid(msg) { + t.Error("buildPresenceMsg output is not valid JSON") + } +} + +func TestBuildChatSendOK_ValidJSON(t *testing.T) { + msg := ws.BuildJSONForTest(map[string]any{ + "type": "chat_send_ok", + "id": "req-1", + "payload": map[string]any{ + "message_id": 1, + "timestamp": "2024-01-01T00:00:00Z", + }, + }) + if !json.Valid(msg) { + t.Error("buildChatSendOK output is not valid JSON") + } +} + +// ─── SendToUser full buffer path (hub.go:308 — 87.5%) ─────────────────────── + +func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "send-full-user") + // Create a send channel with buffer size 1. + send := make(chan []byte, 1) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Fill the buffer. + send <- []byte(`{"type":"filler"}`) + + // Next send should return false (buffer full). + ok := hub.SendToUser(user.ID, []byte(`{"type":"overflow"}`)) + if ok { + t.Error("SendToUser should return false when send buffer is full") + } +} + +// ─── handleChatSend with attachments (handlers.go:127 — 76.2%) ────────────── + +func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { + hub, database := newCoverageHub(t) + // Use a member user. + _, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + chID := seedTestChannel(t, database, "attach-noperm-chan") + + // Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4). + _, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) + if err != nil { + t.Fatalf("INSERT channel_overrides: %v", err) + } + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "msg with attachment", + "attachments": []string{"att-id-1"}, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN for denied ATTACH_FILES permission", code) + } +} + +func TestHandleChatSend_WithAttachments_Success(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "attach-ok-user") + chID := seedTestChannel(t, database, "attach-ok-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "attach-req", + "payload": map[string]any{ + "channel_id": chID, + "content": "msg with attachment", + "attachments": []string{"nonexistent-att-id"}, + }, + }) + hub.HandleMessageForTest(c, raw) + + // Should still succeed (attachments that don't exist are silently skipped). + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { + found = true + break + } + } + if !found { + t.Error("expected chat_send_ok even with nonexistent attachment IDs") + } +} + +// ─── hasChannelPerm with nil user (handlers.go:454) ────────────────────────── + +func TestHasChannelPerm_NilUser_DeniesPermission(t *testing.T) { + hub, database := newCoverageHub(t) + chID := seedTestChannel(t, database, "perm-nil-user-chan") + send := make(chan []byte, 16) + // Create a test client WITHOUT a user (user == nil). + c := ws.NewTestClient(hub, 1, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Try to send a chat message — should get FORBIDDEN due to nil user. + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "should fail", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN for nil user", code) + } +} + +// ─── deliverBroadcast with full send buffer (hub.go:344) ───────────────────── + +func TestDeliverBroadcast_FullBuffer_DropsMessage(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bcast-full-user") + // Create a tiny send buffer. + send := make(chan []byte, 1) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Fill the buffer. + send <- []byte(`{"type":"filler"}`) + + // Broadcasting should not block — message dropped. + hub.BroadcastToAll([]byte(`{"type":"should_be_dropped"}`)) + // Absence assertion: bounded window for the hub loop to (wrongly) enqueue + // the dropped message before checking the buffer is unchanged. + time.Sleep(50 * time.Millisecond) + + // Buffer should still contain only the filler message (dropped msg was not enqueued). + if len(send) != 1 { + t.Errorf("send buffer length = %d, want 1 (dropped message should not be enqueued)", len(send)) + } + // The client should still be registered despite the dropped message. + if !hub.IsUserConnected(user.ID) { + t.Error("client should remain connected after a dropped broadcast") + } + _ = c // keep c referenced +} + +func TestBuildAuthOK_NonNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "authok-avatar-user") + // Set a non-nil avatar. + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) + if err != nil { + t.Fatalf("UPDATE avatar: %v", err) + } + user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + msg := hub.BuildAuthOKForTest(user, "owner") + var env struct { + Payload struct { + User struct { + Avatar string `json:"avatar"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.Avatar != "https://example.com/pic.png" { + t.Errorf("avatar = %q, want https://example.com/pic.png", env.Payload.User.Avatar) + } +} + +// ─── Webhook parse helpers ────────────────────────────────────────────────── + +func TestWebhookParseIdentity_Valid(t *testing.T) { + id, err := ws.ParseIdentityForTest("user-42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 42 { + t.Errorf("id = %d, want 42", id) + } +} + +func TestWebhookParseIdentity_Invalid(t *testing.T) { + _, err := ws.ParseIdentityForTest("invalid") + if err == nil { + t.Fatal("expected error for invalid identity, got nil") + } +} + +func TestWebhookParseRoomChannelID_Valid(t *testing.T) { + id, err := ws.ParseRoomChannelIDForTest("channel-5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 5 { + t.Errorf("id = %d, want 5", id) + } +} + +func TestWebhookParseRoomChannelID_Invalid(t *testing.T) { + _, err := ws.ParseRoomChannelIDForTest("bad") + if err == nil { + t.Fatal("expected error for invalid room name, got nil") + } +} + +// ─── getLastActivity (client.go:153) ───────────────────────────────────────── + +func TestGetLastActivity_ReturnsZeroForNewTestClient(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + got := ws.GetLastActivityForTest(c) + if !got.IsZero() { + t.Fatalf("expected zero time for new test client, got %v", got) + } +} + +func TestGetLastActivity_UpdatedByTouch(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + before := time.Now() + ws.TouchForTest(c) + after := time.Now() + + got := ws.GetLastActivityForTest(c) + if got.Before(before) || got.After(after) { + t.Fatalf("lastActivity = %v, expected between %v and %v", got, before, after) + } +} + +func TestGetLastActivity_MultipleTouch(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.TouchForTest(c) + first := ws.GetLastActivityForTest(c) + + time.Sleep(5 * time.Millisecond) + ws.TouchForTest(c) + second := ws.GetLastActivityForTest(c) + + if !second.After(first) { + t.Fatalf("second touch (%v) should be after first (%v)", second, first) + } +} + +// ─── clearVoiceChID (client.go:203) ───────────────────────────────────────── + +func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetVoiceChIDForTest(c, 42) + old := ws.ClearVoiceChIDForTest(c) + if old != 42 { + t.Fatalf("clearVoiceChID returned %d, want 42", old) + } + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Fatalf("voiceChID after clear = %d, want 0", got) + } +} + +func TestClearVoiceChID_ReturnsZeroWhenNotInVoice(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + old := ws.ClearVoiceChIDForTest(c) + if old != 0 { + t.Fatalf("clearVoiceChID returned %d, want 0", old) + } +} + +func TestClearVoiceChID_DoubleClearReturnsZero(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetVoiceChIDForTest(c, 99) + first := ws.ClearVoiceChIDForTest(c) + second := ws.ClearVoiceChIDForTest(c) + if first != 99 { + t.Fatalf("first clear = %d, want 99", first) + } + if second != 0 { + t.Fatalf("second clear = %d, want 0", second) + } +} + +// ─── BroadcastToChannel / BroadcastToAll full-channel path ────────────────── + +func TestBroadcastToChannel_DropsWhenFull(t *testing.T) { + hub, _ := newCoverageHub(t) + // Don't start Run() — broadcast channel will fill up. + // The broadcast channel capacity is 256. + for range 260 { + hub.BroadcastToChannel(1, []byte(`{"type":"test"}`)) + } + // With no Run() loop draining, some messages are dropped. + // Hub should still be functional after overflow — verify by checking + // that a user lookup still works (hub internals not corrupted). + if hub.IsUserConnected(9999) { + t.Error("expected false for non-existent user after broadcast overflow") + } +} + +func TestBroadcastToAll_DropsWhenFull(t *testing.T) { + hub, _ := newCoverageHub(t) + for range 260 { + hub.BroadcastToAll([]byte(`{"type":"test"}`)) + } + // Hub should still be functional after overflow — verify hub state is intact. + if hub.IsUserConnected(9999) { + t.Error("expected false for non-existent user after broadcast overflow") + } +} diff --git a/Server/ws/coverage_voice_lifecycle_test.go b/Server/ws/coverage_voice_lifecycle_test.go new file mode 100644 index 00000000..ae06dbc8 --- /dev/null +++ b/Server/ws/coverage_voice_lifecycle_test.go @@ -0,0 +1,403 @@ +package ws_test + +// coverage_voice_lifecycle_test.go: voice token refresh, rollback, leave +// retry, cleanup, and stale-state sweep coverage tests (split from +// coverage_boost_test.go). + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// ─── voice_token_refresh (now V2 — dispatched via handleMessage) ──────────── + +func TestHandleVoiceTokenRefresh_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vtr-notinvoice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceTokenRefresh_InVoice_ReturnsToken(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vtr-invc") + vcID := seedVoiceChannel(t, database, "vtr-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": vcID}, + }) + hub.HandleMessageForTest(c, raw) + drainChanTimeout(send, 100*time.Millisecond) + + hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundToken := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_token" { + foundToken = true + break + } + } + if !foundToken { + t.Error("expected voice_token message after token refresh") + } +} + +func TestHandleVoiceTokenRefresh_NilUser(t *testing.T) { + hub, database := newCoverageHub(t) + // The client deliberately carries no *db.User — that is what this test + // covers — but the row must exist so the CONNECT_VOICE re-check can resolve + // a role. Without it the handler stops at FORBIDDEN and never reaches the + // missing-voice-state branch under test. + user := seedCoverageOwner(t, database, "vtr-nil-user") + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, user.ID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + ws.SetVoiceChIDForTest(c, 42) + + hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "INTERNAL" { + t.Errorf("error code = %q, want INTERNAL", code) + } +} + +// ─── rollbackVoiceJoin (voice_join.go:239) ────────────────────────────────── + +func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "rb-user") + vcID := seedVoiceChannel(t, database, "rb-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + ws.SetVoiceChIDForTest(c, vcID) + + // rollbackVoiceJoin, CleanupVoiceForChannel, and sweepStaleVoiceStates are + // synchronous — their client/DB effects are visible as soon as they return. + hub.RollbackVoiceJoinForTest(c, vcID) + + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Fatalf("voiceChID after rollback = %d, want 0", got) + } + + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state != nil { + t.Fatal("voice state should be nil after rollback") + } + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundLeave := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { + foundLeave = true + break + } + } + if !foundLeave { + t.Error("expected voice_leave broadcast after rollback") + } +} + +func TestRollbackVoiceJoin_NoDBState_DoesNotPanic(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "rb-nostate") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + ws.SetVoiceChIDForTest(c, 999) + hub.RollbackVoiceJoinForTest(c, 999) + + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Fatalf("voiceChID after rollback = %d, want 0", got) + } +} + +// ─── leaveVoiceChannelWithRetry (voice_leave.go:57) ───────────────────────── + +func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "lvcr-ok") + vcID := seedVoiceChannel(t, database, "lvcr-ok-vc") + + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state == nil { + t.Fatal("voice state should exist before leave") + } + + err := ws.LeaveVoiceChannelWithRetryForTest(hub, user.ID, vcID, state.JoinedAt) + if err != nil { + t.Fatalf("leaveVoiceChannelWithRetry returned error: %v", err) + } + + state, _ = database.GetVoiceState(context.Background(), user.ID) + if state != nil { + t.Fatal("voice state should be nil after successful leave") + } +} + +func TestLeaveVoiceChannelWithRetry_NoVoiceState_NilReturn(t *testing.T) { + hub, database := newCoverageHub(t) + _ = seedCoverageOwner(t, database, "lvcr-nostate") + + err := ws.LeaveVoiceChannelWithRetryForTest(hub, 9999, 1, "") + if err != nil { + t.Fatalf("expected nil error for non-existent voice state, got: %v", err) + } +} + +// ─── CleanupVoiceForChannel (hub.go:237) — additional paths ───────────────── + +func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { + hub, database := newCoverageHub(t) + user1 := seedCoverageOwner(t, database, "cvfc-u1") + user2 := seedCoverageOwner(t, database, "cvfc-u2") + vcID := seedVoiceChannel(t, database, "cvfc-vc") + + send1 := make(chan []byte, 64) + send2 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c1) + hub.Register(c2) + waitRegistered(t, hub, c2) + + if err := database.JoinVoiceChannel(context.Background(), user1.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel u1: %v", err) + } + if err := database.JoinVoiceChannel(context.Background(), user2.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel u2: %v", err) + } + ws.SetVoiceChIDForTest(c1, vcID) + ws.SetVoiceChIDForTest(c2, vcID) + + hub.CleanupVoiceForChannel(vcID) + + if got := ws.GetClientVoiceChIDForTest(c1); got != 0 { + t.Errorf("c1 voiceChID = %d, want 0", got) + } + if got := ws.GetClientVoiceChIDForTest(c2); got != 0 { + t.Errorf("c2 voiceChID = %d, want 0", got) + } + + states, _ := database.GetChannelVoiceStates(context.Background(), vcID) + if len(states) != 0 { + t.Errorf("expected 0 voice states after cleanup, got %d", len(states)) + } +} + +func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) { + hub, database := newCoverageHub(t) + vcID := seedVoiceChannel(t, database, "cvfc-empty-vc") + hub.CleanupVoiceForChannel(vcID) + + // After cleanup of an empty channel, voice states should still be empty. + states, err := database.GetChannelVoiceStates(context.Background(), vcID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 0 { + t.Errorf("expected 0 voice states after cleaning empty channel, got %d", len(states)) + } +} + +func TestCleanupVoiceForChannel_DBStateButNoClient(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cvfc-noclient") + vcID := seedVoiceChannel(t, database, "cvfc-noclient-vc") + + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + hub.CleanupVoiceForChannel(vcID) + + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state != nil { + t.Error("voice state should be nil after cleanup") + } +} + +// ─── sweepStaleVoiceStates (hub.go:489) ───────────────────────────────────── + +func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "sweep-ghost") + vcID := seedVoiceChannel(t, database, "sweep-ghost-vc") + + // Put user in voice in DB but don't register a client — ghost state. + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + // Verify it exists. + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state == nil { + t.Fatal("voice state should exist before sweep") + } + + hub.SweepStaleVoiceStatesForTest() + + // Ghost state should be removed. + state, _ = database.GetVoiceState(context.Background(), user.ID) + if state != nil { + t.Error("ghost voice state should be nil after sweep") + } +} + +func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "sweep-active") + vcID := seedVoiceChannel(t, database, "sweep-active-vc") + + // Register client and set voice channel. + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + ws.SetVoiceChIDForTest(c, vcID) + + hub.SweepStaleVoiceStatesForTest() + + // Active client's state should be preserved. + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state == nil { + t.Error("active client's voice state should be preserved after sweep") + } +} + +func TestSweepStaleVoiceStates_NoStatesNoPanic(t *testing.T) { + hub, database := newCoverageHub(t) + hub.SweepStaleVoiceStatesForTest() + + // With no voice states in the DB, sweep should leave the system clean. + // Verify by checking a known user has no voice state. + user := seedCoverageOwner(t, database, "sweep-no-states") + state, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state != nil { + t.Error("expected nil voice state for user after sweep with no states") + } +} + +func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "sweep-mismatch") + vc1 := seedVoiceChannel(t, database, "sweep-mismatch-vc1") + vc2 := seedVoiceChannel(t, database, "sweep-mismatch-vc2") + + // Register client in vc1 but DB says vc2. + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + if err := database.JoinVoiceChannel(context.Background(), user.ID, vc2); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + ws.SetVoiceChIDForTest(c, vc1) // Client thinks vc1, DB says vc2 — mismatch. + + hub.SweepStaleVoiceStatesForTest() + + // Mismatched state should be removed from DB. + state, _ := database.GetVoiceState(context.Background(), user.ID) + if state != nil { + t.Error("mismatched voice state should be removed after sweep") + } +} + +// TestSweepStaleVoiceStates_EvictsRevokedConnectVoice locks the revocation half +// of the voice-permission invariant: nothing in ws re-validated CONNECT_VOICE +// for a connection that stays open, so stripping the bit blocked future joins +// but left the offender in the room. The sweep must now evict them — DB row +// gone and the client's own voice state cleared. +func TestSweepStaleVoiceStates_EvictsRevokedConnectVoice(t *testing.T) { + hub, database := newCoverageHub(t) + // Member role (id 4), not Owner: admins bypass every channel check. + if _, err := database.CreateUser(context.Background(), "sweep-revoked", "hash", 4); err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(context.Background(), "sweep-revoked") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + vcID := seedVoiceChannel(t, database, "sweep-revoked-vc") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + if joinErr := database.JoinVoiceChannel(context.Background(), user.ID, vcID); joinErr != nil { + t.Fatalf("JoinVoiceChannel: %v", joinErr) + } + vs, err := database.GetVoiceState(context.Background(), user.ID) + if err != nil || vs == nil { + t.Fatalf("GetVoiceState after join: %v", err) + } + ws.SetClientVoiceStateForTest(c, vcID, vs.JoinedAt) + + // Still permitted → the sweep leaves them alone. + hub.SweepStaleVoiceStatesForTest() + if state, _ := database.GetVoiceState(context.Background(), user.ID); state == nil { + t.Fatal("a permitted participant must survive the sweep") + } + + // Moderator revokes CONNECT_VOICE on this channel for the Member role. + if permErr := database.UpsertChannelOverride( + context.Background(), vcID, 4, 0, permissions.ConnectVoice, + ); permErr != nil { + t.Fatalf("UpsertChannelOverride: %v", permErr) + } + + hub.SweepStaleVoiceStatesForTest() + + if state, _ := database.GetVoiceState(context.Background(), user.ID); state != nil { + t.Error("revoked participant's voice state must be deleted by the sweep") + } + if chID := ws.GetClientVoiceChIDForTest(c); chID != 0 { + t.Errorf("revoked participant's client voice state must be cleared, got channel %d", chID) + } +} diff --git a/Server/ws/coverage_voice_test.go b/Server/ws/coverage_voice_test.go new file mode 100644 index 00000000..4bdaf5c7 --- /dev/null +++ b/Server/ws/coverage_voice_test.go @@ -0,0 +1,880 @@ +package ws_test + +// coverage_voice_test.go: voice handler validation and full-flow coverage +// tests (split from coverage_boost_test.go). + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// ─── voice handler edge cases ──────────────────────────────────────────────── + +func TestHandleVoiceJoin_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": "not-a-number", + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceJoin_NegativeChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-neg-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceMute_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vm-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Put client in voice so the "not in voice" guard doesn't fire first. + ws.SetClientVoiceChID(c, 999) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_mute payload", code) + } +} + +func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vd-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Put client in voice so the "not in voice" guard doesn't fire first. + ws.SetClientVoiceChID(c, 999) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_deafen payload", code) + } +} + +// ─── voice camera and screenshare error paths ──────────────────────────────── + +func TestHandleVoiceCamera_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestHandleVoiceCamera_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-bad-payload") + vcID, err := database.CreateChannel(context.Background(), "cam-vc", "voice", "", "", 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) + + // Set voice channel so the not-in-voice check passes. + ws.SetClientVoiceChID(c, vcID) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-bad-payload") + vcID, err := database.CreateChannel(context.Background(), "screen-vc", "voice", "", "", 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) + + ws.SetClientVoiceChID(c, vcID) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +// ─── voice join/leave full flow (voice_handlers.go coverage) ───────────────── + +func TestHandleVoiceJoin_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-flow-user") + vcID := seedVoiceChannel(t, database, "vj-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + + msgs := drainChanTimeout(send, 500*time.Millisecond) + foundState := false + foundConfig := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + switch env["type"] { + case "voice_state": + foundState = true + case "voice_config": + foundConfig = true + } + } + } + if !foundState { + t.Error("expected voice_state broadcast after voice_join") + } + if !foundConfig { + t.Error("expected voice_config after voice_join") + } +} + +// TestVoiceState_NotDeliveredToRolesDeniedRead locks the visibility invariant +// for voice metadata: voice_state / voice_leave used to go out via +// BroadcastToAll, so every authenticated client learned the membership and +// camera/mute state of voice channels that channel_overrides hides from their +// role — even though the ready payload deliberately filters them out. A member +// who can read the channel must still receive them. +func TestVoiceState_NotDeliveredToRolesDeniedRead(t *testing.T) { + hub, database := newCoverageHub(t) + joiner := seedCoverageOwner(t, database, "vs-joiner") + vcID := seedVoiceChannel(t, database, "vs-private-vc") + + // Two plain members (role 4). One is locked out of the channel with the + // override the admin panel writes when "Can access" is unchecked. + newMember := func(name string) *db.User { + t.Helper() + if _, err := database.CreateUser(context.Background(), name, "hash", 4); err != nil { + t.Fatalf("CreateUser %s: %v", name, err) + } + u, err := database.GetUserByUsername(context.Background(), name) + if err != nil || u == nil { + t.Fatalf("GetUserByUsername %s: %v", name, err) + } + return u + } + insider := newMember("vs-insider") + outsiderRole := int64(3) // Moderator: a distinct role so the deny is role-scoped + outsider := newMember("vs-outsider") + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET role_id = ? WHERE id = ?`, outsiderRole, outsider.ID, + ); err != nil { + t.Fatalf("reassign outsider role: %v", err) + } + if err := database.UpsertChannelOverride(context.Background(), vcID, outsiderRole, 0, + permissions.ReadMessages|permissions.ConnectVoice); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + insiderSend := make(chan []byte, 64) + outsiderSend := make(chan []byte, 64) + hub.Register(ws.NewTestClientWithUser(hub, insider, 0, insiderSend)) + hub.Register(ws.NewTestClientWithUser(hub, outsider, 0, outsiderSend)) + + joinerSend := make(chan []byte, 64) + jc := ws.NewTestClientWithUser(hub, joiner, 0, joinerSend) + hub.Register(jc) + waitRegistered(t, hub, jc) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": vcID}, + }) + hub.HandleMessageForTest(jc, raw) + + countVoiceState := func(ch <-chan []byte) int { + n := 0 + for _, msg := range drainChanTimeout(ch, 300*time.Millisecond) { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + n++ + } + } + return n + } + if got := countVoiceState(insiderSend); got == 0 { + t.Error("a member who may READ the channel must still receive voice_state") + } + if got := countVoiceState(outsiderSend); got != 0 { + t.Errorf("a role denied READ received %d voice_state events, want 0", got) + } +} + +func TestHandleVoiceJoin_AlreadyInSameChannel(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-same-user") + vcID := seedVoiceChannel(t, database, "vj-same-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + drainChanTimeout(send, 100*time.Millisecond) + + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "ALREADY_JOINED" { + t.Errorf("error code = %q, want ALREADY_JOINED", code) + } +} + +func TestHandleVoiceJoin_SwitchChannels(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-switch-user") + vc1 := seedVoiceChannel(t, database, "vj-switch-vc1") + vc2 := seedVoiceChannel(t, database, "vj-switch-vc2") + send := make(chan []byte, 128) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw1, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vc1, + }, + }) + hub.HandleMessageForTest(c, raw1) + drainChanTimeout(send, 100*time.Millisecond) + + raw2, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vc2, + }, + }) + hub.HandleMessageForTest(c, raw2) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundLeave := false + foundConfig := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + switch env["type"] { + case "voice_leave": + foundLeave = true + case "voice_config": + foundConfig = true + } + } + } + if !foundLeave { + t.Error("expected voice_leave broadcast when switching channels") + } + if !foundConfig { + t.Error("expected voice_config for new channel") + } +} + +func TestHandleVoiceJoin_ChannelFull(t *testing.T) { + hub, database := newCoverageHub(t) + vcID, err := database.CreateChannel(context.Background(), "full-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE channels: %v", err) + } + + user1 := seedCoverageOwner(t, database, "vj-full-u1") + send1 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + waitRegistered(t, hub, c1) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, raw) + drainChanTimeout(send1, 100*time.Millisecond) + + user2 := seedCoverageOwner(t, database, "vj-full-u2") + send2 := make(chan []byte, 64) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + waitRegistered(t, hub, c2) + + hub.HandleMessageForTest(c2, raw) + + code := drainForErrorCode(send2, 300*time.Millisecond) + if code != "CHANNEL_FULL" { + t.Errorf("error code = %q, want CHANNEL_FULL", code) + } +} + +func TestHandleVoiceLeave_ExplicitLeave(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vl-explicit-user") + vcID := seedVoiceChannel(t, database, "vl-explicit-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + drainChanTimeout(send, 100*time.Millisecond) + + leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) + hub.HandleMessageForTest(c, leaveRaw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundLeave := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { + foundLeave = true + break + } + } + if !foundLeave { + t.Error("expected voice_leave broadcast after explicit leave") + } +} + +func TestHandleVoiceLeave_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vl-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // handleVoiceLeave is synchronous — a no-op leave returns with state intact. + hub.HandleVoiceLeaveForTest(c) + + // Client should still be connected and have no voice channel set. + if !hub.IsUserConnected(user.ID) { + t.Error("user should still be connected after no-op voice leave") + } + if got := ws.GetClientVoiceChIDForTest(c); got != 0 { + t.Errorf("voiceChID = %d, want 0 after leave when not in voice", got) + } +} + +func TestHandleVoiceMute_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vm-flow-user") + vcID := seedVoiceChannel(t, database, "vm-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + drainChanTimeout(send, 100*time.Millisecond) + + muteRaw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": map[string]any{ + "muted": true, + }, + }) + hub.HandleMessageForTest(c, muteRaw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state broadcast after mute") + } +} + +func TestHandleVoiceDeafen_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vd-flow-user") + vcID := seedVoiceChannel(t, database, "vd-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + drainChanTimeout(send, 100*time.Millisecond) + + deafenRaw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": map[string]any{ + "deafened": true, + }, + }) + hub.HandleMessageForTest(c, deafenRaw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state broadcast after deafen") + } +} + +func TestHandleVoiceJoin_ChannelNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-notfound-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": 99999, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("error code = %q, want NOT_FOUND", code) + } +} + +func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-quality-user") + + vcID, err := database.CreateChannel(context.Background(), "quality-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { + p := env["payload"].(map[string]any) + if p["quality"] != "high" { + t.Errorf("voice_config quality = %v, want high", p["quality"]) + } + return + } + } + t.Error("expected voice_config with quality override") +} + +func TestHandleVoiceJoin_MultipleParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + vcID := seedVoiceChannel(t, database, "vj-multi-vc") + + user1 := seedCoverageOwner(t, database, "vj-multi-u1") + send1 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + waitRegistered(t, hub, c1) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, raw) + drainChanTimeout(send1, 100*time.Millisecond) + + user2 := seedCoverageOwner(t, database, "vj-multi-u2") + send2 := make(chan []byte, 64) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + waitRegistered(t, hub, c2) + + hub.HandleMessageForTest(c2, raw) + + msgs := drainChanTimeout(send2, 300*time.Millisecond) + voiceStateCount := 0 + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + voiceStateCount++ + } + } + if voiceStateCount < 2 { + t.Errorf("voice_state count = %d, want at least 2", voiceStateCount) + } +} + +func TestHandleVoiceLeave_BroadcastsToOtherParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + vcID := seedVoiceChannel(t, database, "vl-bcast-vc") + + user1 := seedCoverageOwner(t, database, "vl-bcast-u1") + user2 := seedCoverageOwner(t, database, "vl-bcast-u2") + send1 := make(chan []byte, 64) + send2 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c1) + hub.Register(c2) + waitRegistered(t, hub, c2) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, joinRaw) + hub.HandleMessageForTest(c2, joinRaw) + drainChanTimeout(send1, 100*time.Millisecond) + drainChanBuf(send2) + + leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) + hub.HandleMessageForTest(c1, leaveRaw) + + msgs := drainChanTimeout(send2, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { + found = true + break + } + } + if !found { + t.Error("user2 should receive voice_leave when user1 leaves") + } +} + +func TestHandleVoiceCamera_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-flow-user") + vcID := seedVoiceChannel(t, database, "vc-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + drainChanTimeout(send, 100*time.Millisecond) + + camRaw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, camRaw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state after camera toggle") + } +} + +func TestHandleVoiceScreenshare_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-flow-user") + vcID := seedVoiceChannel(t, database, "vs-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + drainChanTimeout(send, 100*time.Millisecond) + + ssRaw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, ssRaw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state after screenshare toggle") + } +} + +// ─── Voice control "not in voice" guards ──────────────────────────────────── + +func TestHandleVoiceMute_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vm-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": map[string]any{ + "muted": true, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestHandleVoiceDeafen_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vd-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": map[string]any{ + "deafened": true, + }, + }) + hub.HandleMessageForTest(c, raw) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +// ─── Voice join with invalid quality fallback ─────────────────────────────── + +func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-badquality-user") + + vcID, err := database.CreateChannel(context.Background(), "badquality-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { + p := env["payload"].(map[string]any) + if p["quality"] != "medium" { + t.Errorf("voice_config quality = %v, want medium", p["quality"]) + } + return + } + } + t.Error("expected voice_config with medium quality fallback") +} diff --git a/Server/ws/deps.go b/Server/ws/deps.go index fd6e2886..723b0114 100644 --- a/Server/ws/deps.go +++ b/Server/ws/deps.go @@ -75,20 +75,34 @@ type VoiceDeps struct { DB *db.DB Limiter *auth.RateLimiter Permissions *permissions.Checker - LiveKit *LiveKitClient - TokenGen VoiceTokenGenerator // used by voice_token_refresh V2 - KeyHolder KeyHolderChecker // used by voice_token_refresh V2 + // PermSvc is the cached permission service. When non-nil the permission + // helpers below answer from its per-user cache instead of per-call role and + // override queries; when nil (tests constructing bare deps) they keep the + // live DB path and fail closed exactly as before. + PermSvc *service.PermissionService + LiveKit *LiveKitClient + TokenGen VoiceTokenGenerator // used by voice_token_refresh V2 + KeyHolder KeyHolderChecker // used by voice_token_refresh V2 } // ── V2 permission helpers ─────────────────────────────────────────────────── -// requirePerm checks a channel permission via DB lookups. Returns nil if -// allowed, or a Result carrying either an INTERNAL error (when the server -// is misconfigured or a DB lookup fails) or a FORBIDDEN error (when the -// permission bit is genuinely absent from the user's role). Previously -// every branch returned FORBIDDEN, which hid operator-visible failures -// behind a user-facing permission denial. -func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result { +// requirePerm checks a channel permission. Returns nil if allowed, or a Result +// carrying either an INTERNAL error (when the server is misconfigured or a DB +// lookup fails) or a FORBIDDEN error (when the permission bit is genuinely +// absent from the user's role). Previously every branch returned FORBIDDEN, +// which hid operator-visible failures behind a user-facing permission denial. +// +// A positive verdict from the cached PermissionService is taken as-is (grants +// are invalidated synchronously at every mutation site, so it cannot be a +// stale allow beyond the invalidation contract). A negative verdict falls +// through to the live path because the cache's boolean cannot express the +// INTERNAL-vs-FORBIDDEN distinction above — denials are the rare case, so the +// extra lookups only happen when the check is about to fail anyway. +func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checker, permSvc *service.PermissionService, userID, channelID, perm int64, label string) *Result { + if permSvc != nil && permSvc.HasChannelPerm(ctx, userID, channelID, perm) { + return nil + } if database == nil || perms == nil { // Missing dependency is a server bug, not a user ACL outcome. Log // here so operators see something even when the client surfaces a @@ -117,8 +131,14 @@ func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checke return nil } -// hasPerm checks a channel permission via DB lookups. Returns true if allowed. -func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { +// hasPerm checks a channel permission. Returns true if allowed. With a +// PermissionService the answer comes from its per-user cache (false on any +// lookup failure, same fail-closed posture as the live path); without one it +// falls back to per-call DB lookups. +func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, permSvc *service.PermissionService, userID, channelID, perm int64) bool { + if permSvc != nil { + return permSvc.HasChannelPerm(ctx, userID, channelID, perm) + } if database == nil || perms == nil { return false } @@ -152,7 +172,44 @@ func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, u // (service.requireDMNotBlocked), it is two-party only, and a blocked user is // still a participant, so it is orthogonal to the non-participant hole this // closes. -func hasChannelAccess(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { +// +// With a PermissionService the role-bit gate is answered from its per-user +// cache (the channel-type lookup and the DM membership check stay live — +// dm_participants rows are membership, not permission, state and are never +// cached). Both branches enforce the same rule: role bit required on top, DM +// membership via the single shared IsDMParticipant definition. +func hasChannelAccess(ctx context.Context, database *db.DB, perms *permissions.Checker, permSvc *service.PermissionService, userID, channelID, perm int64) bool { + if database == nil { + return false + } + if permSvc == nil { + return hasChannelAccessLive(ctx, database, perms, userID, channelID, perm) + } + if !permSvc.HasChannelPerm(ctx, userID, channelID, perm) { + return false + } + ch, err := database.GetChannel(ctx, channelID) + if err != nil { + // Fail closed: an unknown type would silently take the non-DM path. + slog.Error("ws: hasChannelAccess GetChannel failed, denying", + "user_id", userID, "channel_id", channelID, "err", err) + return false + } + // A missing channel row takes the non-DM branch, i.e. the role verdict + // above stands: there is no DM there to join, and callers keep reporting a + // deleted channel the way they always have. + if ch == nil || ch.Type != "dm" { + return true + } + // DM: for "dm" the service's RequireChannelAccess is exactly the + // IsDMParticipant membership rule (it waives the role check, which was + // already enforced above). + return permSvc.RequireChannelAccess(ctx, userID, ch.Type, channelID, perm) == nil +} + +// hasChannelAccessLive is the uncached hasChannelAccess path, kept verbatim for +// hubs and deps constructed without a PermissionService (bare test fixtures). +func hasChannelAccessLive(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { if database == nil || perms == nil { return false } @@ -170,16 +227,19 @@ func hasChannelAccess(ctx context.Context, database *db.DB, perms *permissions.C "user_id", userID, "channel_id", channelID, "err", err) return false } - channelType := "" - if ch != nil { - channelType = ch.Type - } - // A missing channel row leaves channelType empty, i.e. the role verdict + // A missing channel row takes the non-DM branch, i.e. the role verdict // above stands: there is no DM there to join, and callers keep reporting a - // deleted channel the way they always have. For every non-DM type this call - // just re-runs the role check above; the repeated lookup is the price of one - // shared definition of the rule, on a per-user rate-limited path. - return perms.RequireChannelAccess(ctx, userID, role.Permissions, role.ID, channelType, channelID, perm) == nil + // deleted channel the way they always have. + if ch == nil || ch.Type != "dm" { + // For every non-DM type, RequireChannelAccess is defined as exactly the + // HasChannelPerm call already made above, so re-invoking it would only + // repeat the same override lookup. The role verdict is the answer. + return true + } + // DM: the role bit above stays required on top; the membership rule keeps + // its single shared definition in RequireChannelAccess (IsDMParticipant), + // which waives the role check for DMs. + return perms.RequireChannelAccess(ctx, userID, role.Permissions, role.ID, ch.Type, channelID, perm) == nil } // ── V2 handler type ───────────────────────────────────────────────────────── diff --git a/Server/ws/dm_handlers_test.go b/Server/ws/dm_handlers_test.go index e2b40f9e..e11024fb 100644 --- a/Server/ws/dm_handlers_test.go +++ b/Server/ws/dm_handlers_test.go @@ -120,6 +120,46 @@ func dmDrainAll(ch <-chan []byte) []map[string]any { } } +// dmWaitMsgType blocks until a message with the given type arrives on ch, +// returning its envelope, or returns nil when the timeout expires. +func dmWaitMsgType(ch <-chan []byte, msgType string, timeout time.Duration) map[string]any { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case raw := <-ch: + var env map[string]any + if err := json.Unmarshal(raw, &env); err != nil { + continue + } + if env["type"] == msgType { + return env + } + case <-timer.C: + return nil + } + } +} + +// dmCollectAll reads messages for the full window d and returns the decoded +// envelopes. Use for absence assertions — the window always elapses. +func dmCollectAll(ch <-chan []byte, d time.Duration) []map[string]any { + var result []map[string]any + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case raw := <-ch: + var env map[string]any + if err := json.Unmarshal(raw, &env); err == nil { + result = append(result, env) + } + case <-timer.C: + return result + } + } +} + // dmFindMsgType returns the first message of the given type from a slice of envelopes. func dmFindMsgType(msgs []map[string]any, msgType string) map[string]any { for _, m := range msgs { @@ -157,22 +197,17 @@ func TestDM_ChatSend_ParticipantSuccess(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "hello bob")) - time.Sleep(100 * time.Millisecond) // Alice should get chat_send_ok ack. - aliceMsgs := dmDrainAll(sendAlice) - ack := dmFindMsgType(aliceMsgs, "chat_send_ok") - if ack == nil { + if dmWaitMsgType(sendAlice, "chat_send_ok", waitTimeout) == nil { t.Error("Alice did not receive chat_send_ok") } // Bob should get a chat_message via SendToUser. - bobMsgs := dmDrainAll(sendBob) - msg := dmFindMsgType(bobMsgs, "chat_message") - if msg == nil { + if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil { t.Error("Bob did not receive chat_message") } } @@ -189,15 +224,13 @@ func TestDM_ChatSend_SequencedAndReplayBuffered(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m1")) hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m2")) hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "m3")) - time.Sleep(120 * time.Millisecond) - bobMsgs := dmDrainAll(sendBob) - chat := dmFindMsgType(bobMsgs, "chat_message") + chat := dmWaitMsgType(sendBob, "chat_message", waitTimeout) if chat == nil { t.Fatal("Bob did not receive any DM chat_message") } @@ -226,11 +259,11 @@ func TestDM_ChatSend_NonParticipantForbidden(t *testing.T) { sendCharlie := make(chan []byte, 64) cCharlie := ws.NewTestClientWithUser(hub, charlie, 0, sendCharlie) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cCharlie, dmChatSendMsg(dmChID, "intruder")) - time.Sleep(100 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendCharlie) code := dmFindErrorCode(msgs) if code != "FORBIDDEN" { @@ -255,20 +288,16 @@ func TestDM_ChatSend_AutoReopenForRecipient(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, 0, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) // Alice sends a message — should auto-reopen for Bob. hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "hey bob")) - time.Sleep(100 * time.Millisecond) // Bob should receive both a dm_channel_open and the chat_message. - bobMsgs := dmDrainAll(sendBob) - openMsg := dmFindMsgType(bobMsgs, "dm_channel_open") - if openMsg == nil { + if dmWaitMsgType(sendBob, "dm_channel_open", waitTimeout) == nil { t.Error("Bob did not receive dm_channel_open on auto-reopen") } - chatMsg := dmFindMsgType(bobMsgs, "chat_message") - if chatMsg == nil { + if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil { t.Error("Bob did not receive chat_message after auto-reopen") } } @@ -290,15 +319,12 @@ func TestDM_ChatEdit_ParticipantCanEdit(t *testing.T) { sendAlice := make(chan []byte, 64) cAlice := ws.NewTestClientWithUser(hub, alice, dmChID, sendAlice) hub.Register(cAlice) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cAlice) hub.HandleMessageForTest(cAlice, dmChatEditMsg(msgID, "edited")) - time.Sleep(100 * time.Millisecond) // Alice should receive the chat_edited broadcast (via the sequenced DM event path). - msgs := dmDrainAll(sendAlice) - edited := dmFindMsgType(msgs, "chat_edited") - if edited == nil { + if dmWaitMsgType(sendAlice, "chat_edited", waitTimeout) == nil { t.Error("participant did not receive chat_edited for DM") } } @@ -319,11 +345,11 @@ func TestDM_ChatEdit_NonParticipantForbidden(t *testing.T) { sendCharlie := make(chan []byte, 64) cCharlie := ws.NewTestClientWithUser(hub, charlie, 0, sendCharlie) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cCharlie, dmChatEditMsg(msgID, "hacked")) - time.Sleep(100 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendCharlie) code := dmFindErrorCode(msgs) if code != "FORBIDDEN" { @@ -350,18 +376,15 @@ func TestDM_ChatDelete_ParticipantCanDeleteOwn(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cAlice, dmChatDeleteMsg(msgID)) - time.Sleep(100 * time.Millisecond) // Both participants should receive chat_deleted. - aliceMsgs := dmDrainAll(sendAlice) - if dmFindMsgType(aliceMsgs, "chat_deleted") == nil { + if dmWaitMsgType(sendAlice, "chat_deleted", waitTimeout) == nil { t.Error("Alice did not receive chat_deleted") } - bobMsgs := dmDrainAll(sendBob) - if dmFindMsgType(bobMsgs, "chat_deleted") == nil { + if dmWaitMsgType(sendBob, "chat_deleted", waitTimeout) == nil { t.Error("Bob did not receive chat_deleted") } } @@ -381,11 +404,11 @@ func TestDM_ChatDelete_NonParticipantForbidden(t *testing.T) { sendCharlie := make(chan []byte, 64) cCharlie := ws.NewTestClientWithUser(hub, charlie, 0, sendCharlie) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cCharlie, dmChatDeleteMsg(msgID)) - time.Sleep(100 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendCharlie) code := dmFindErrorCode(msgs) if code != "FORBIDDEN" { @@ -409,13 +432,13 @@ func TestDM_ChatDelete_NoModeratorOverride(t *testing.T) { sendAlice := make(chan []byte, 64) cAlice := ws.NewTestClientWithUser(hub, alice, dmChID, sendAlice) hub.Register(cAlice) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cAlice) // Alice (Owner role) tries to delete Bob's message — should fail because // DMs disable moderator override. hub.HandleMessageForTest(cAlice, dmChatDeleteMsg(msgID)) - time.Sleep(100 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendAlice) code := dmFindErrorCode(msgs) if code != "FORBIDDEN" { @@ -437,15 +460,12 @@ func TestDM_Typing_ParticipantBroadcasts(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cAlice, dmTypingMsg(dmChID)) - time.Sleep(100 * time.Millisecond) // Bob should receive typing broadcast (type is "typing", not "typing_start"). - bobMsgs := dmDrainAll(sendBob) - typing := dmFindMsgType(bobMsgs, "typing") - if typing == nil { + if dmWaitMsgType(sendBob, "typing", waitTimeout) == nil { t.Error("Bob did not receive typing in DM") } } @@ -466,19 +486,14 @@ func TestDM_Typing_NonParticipantSilentlyDropped(t *testing.T) { hub.Register(cCharlie) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cCharlie, dmTypingMsg(dmChID)) - time.Sleep(100 * time.Millisecond) - // Charlie should NOT receive an error — typing from non-participants is silently dropped. - charlieMsgs := dmDrainAll(sendCharlie) - if code := dmFindErrorCode(charlieMsgs); code != "" { - t.Errorf("non-participant typing should be silently dropped, got error: %s", code) - } - - // Alice and Bob should NOT receive typing from Charlie. - aliceMsgs := dmDrainAll(sendAlice) + // Alice and Bob should NOT receive typing from Charlie. The bounded window + // on Alice's channel doubles as the settle time for Bob's (a wrongly routed + // typing broadcast would be fanned out to both in the same delivery pass). + aliceMsgs := dmCollectAll(sendAlice, 100*time.Millisecond) if dmFindMsgType(aliceMsgs, "typing") != nil { t.Error("Alice received typing from non-participant Charlie") } @@ -486,6 +501,13 @@ func TestDM_Typing_NonParticipantSilentlyDropped(t *testing.T) { if dmFindMsgType(bobMsgs, "typing") != nil { t.Error("Bob received typing from non-participant Charlie") } + + // Charlie should NOT receive an error — typing from non-participants is + // silently dropped (error replies would have been sent synchronously). + charlieMsgs := dmDrainAll(sendCharlie) + if code := dmFindErrorCode(charlieMsgs); code != "" { + t.Errorf("non-participant typing should be silently dropped, got error: %s", code) + } } // ─── channel_focus DM branch ──────────────────────────────────────────────── @@ -499,12 +521,11 @@ func TestDM_ChannelFocus_ParticipantAllowed(t *testing.T) { sendAlice := make(chan []byte, 64) cAlice := ws.NewTestClientWithUser(hub, alice, 0, sendAlice) hub.Register(cAlice) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cAlice) hub.HandleMessageForTest(cAlice, dmChannelFocusMsg(dmChID)) - time.Sleep(50 * time.Millisecond) - // No error should be sent. + // No error should be sent (error replies are synchronous — already buffered). msgs := dmDrainAll(sendAlice) if code := dmFindErrorCode(msgs); code != "" { t.Errorf("participant channel_focus got error: %s", code) @@ -521,11 +542,11 @@ func TestDM_ChannelFocus_NonParticipantRejected(t *testing.T) { sendCharlie := make(chan []byte, 64) cCharlie := ws.NewTestClientWithUser(hub, charlie, 0, sendCharlie) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cCharlie, dmChannelFocusMsg(dmChID)) - time.Sleep(50 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendCharlie) code := dmFindErrorCode(msgs) if code != "FORBIDDEN" { @@ -552,18 +573,15 @@ func TestDM_ReactionAdd_ParticipantSuccess(t *testing.T) { cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cAlice) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) hub.HandleMessageForTest(cBob, dmReactionAddMsg(msgID, "👍")) - time.Sleep(100 * time.Millisecond) // Both participants should get reaction_update broadcast. - aliceMsgs := dmDrainAll(sendAlice) - if dmFindMsgType(aliceMsgs, "reaction_update") == nil { + if dmWaitMsgType(sendAlice, "reaction_update", waitTimeout) == nil { t.Error("Alice did not receive reaction_update in DM") } - bobMsgs := dmDrainAll(sendBob) - if dmFindMsgType(bobMsgs, "reaction_update") == nil { + if dmWaitMsgType(sendBob, "reaction_update", waitTimeout) == nil { t.Error("Bob did not receive reaction_update in DM") } } @@ -583,11 +601,11 @@ func TestDM_ReactionAdd_NonParticipantError(t *testing.T) { sendCharlie := make(chan []byte, 64) cCharlie := ws.NewTestClientWithUser(hub, charlie, 0, sendCharlie) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cCharlie, dmReactionAddMsg(msgID, "👎")) - time.Sleep(100 * time.Millisecond) + // Error replies are sent synchronously by handleMessage — already buffered. msgs := dmDrainAll(sendCharlie) code := dmFindErrorCode(msgs) // Non-participant reaction returns BAD_REQUEST (normalized to prevent IDOR info leak). @@ -610,19 +628,18 @@ func TestDM_ReactionRemove_ParticipantSuccess(t *testing.T) { sendBob := make(chan []byte, 64) cBob := ws.NewTestClientWithUser(hub, bob, dmChID, sendBob) hub.Register(cBob) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cBob) - // Add a reaction first. + // Add a reaction first and consume its reaction_update broadcast. hub.HandleMessageForTest(cBob, dmReactionAddMsg(msgID, "🔥")) - time.Sleep(50 * time.Millisecond) - dmDrainAll(sendBob) // clear + if dmWaitMsgType(sendBob, "reaction_update", waitTimeout) == nil { + t.Fatal("participant did not receive reaction_update (add) in DM") + } // Remove the reaction. hub.HandleMessageForTest(cBob, dmReactionRemoveMsg(msgID, "🔥")) - time.Sleep(100 * time.Millisecond) - msgs := dmDrainAll(sendBob) - if dmFindMsgType(msgs, "reaction_update") == nil { + if dmWaitMsgType(sendBob, "reaction_update", waitTimeout) == nil { t.Error("participant did not receive reaction_update (remove) in DM") } } @@ -646,22 +663,21 @@ func TestDM_ChatSend_DeliveredViaSendToUser(t *testing.T) { hub.Register(cAlice) hub.Register(cBob) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmChID, "private to bob")) - time.Sleep(100 * time.Millisecond) - - // Charlie should NOT receive the DM message. - charlieMsgs := dmDrainAll(sendCharlie) - if dmFindMsgType(charlieMsgs, "chat_message") != nil { - t.Error("Charlie (non-participant) received DM chat_message — should be delivered only via SendToUser") - } // Bob SHOULD receive it. - bobMsgs := dmDrainAll(sendBob) - if dmFindMsgType(bobMsgs, "chat_message") == nil { + if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil { t.Error("Bob did not receive DM chat_message") } + + // Charlie should NOT receive the DM message — bounded absence window after + // Bob's copy has already been delivered. + charlieMsgs := dmCollectAll(sendCharlie, 50*time.Millisecond) + if dmFindMsgType(charlieMsgs, "chat_message") != nil { + t.Error("Charlie (non-participant) received DM chat_message — should be delivered only via SendToUser") + } } // ─── Multiple DM channels isolation ───────────────────────────────────────── @@ -685,14 +701,19 @@ func TestDM_MultipleChannels_IsolatedDelivery(t *testing.T) { hub.Register(cAlice) hub.Register(cBob) hub.Register(cCharlie) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cCharlie) // Alice sends to Alice-Bob DM. hub.HandleMessageForTest(cAlice, dmChatSendMsg(dmAB, fmt.Sprintf("only for bob %d", dmAB))) - time.Sleep(100 * time.Millisecond) + + // Bob's copy arriving proves delivery completed; then Charlie gets a + // bounded absence window. + if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil { + t.Fatal("Bob did not receive the Alice-Bob DM message") + } // Charlie should NOT get this message. - charlieMsgs := dmDrainAll(sendCharlie) + charlieMsgs := dmCollectAll(sendCharlie, 50*time.Millisecond) if dmFindMsgType(charlieMsgs, "chat_message") != nil { t.Error("Charlie received message from Alice-Bob DM") } diff --git a/Server/ws/emit_test.go b/Server/ws/emit_test.go index f8e1fdd6..1adeb202 100644 --- a/Server/ws/emit_test.go +++ b/Server/ws/emit_test.go @@ -73,6 +73,9 @@ func registerEmitTestVoiceClient(h *Hub, userID, channelID, voiceChID int64) cha } if voiceChID > 0 { h.pubsub.Subscribe(c, ChannelTopic(voiceChID)) + // A real voice join also subscribes the voice topic (voice_join.go), + // which is how sendToVoiceChannelExcept reaches participants. + h.pubsub.Subscribe(c, VoiceTopic(voiceChID)) } return send } diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go index 90ea2dd3..6afd6af4 100644 --- a/Server/ws/event_persister.go +++ b/Server/ws/event_persister.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/owncord/server/db" "github.com/owncord/server/telemetry" ) @@ -144,21 +145,35 @@ func (p *EventPersister) run(ctx context.Context) { metrics := telemetry.NewAppMetrics() batch := make([]pendingEvent, 0, p.batchSize) + // Scratch slice reused across flushes for the store's batch shape. + rows := make([]db.PersistedEvent, 0, p.batchSize) flush := func() { if len(batch) == 0 { return } p.flushes.Add(1) + rows = rows[:0] for _, evt := range batch { - if err := p.store.PersistEvent(ctx, evt.seq, evt.eventType, evt.channelID, evt.payload); err != nil { - p.errors.Add(1) - metrics.WSEventsPersistErrors.Add(ctx, 1) - slog.Warn("event persister: PersistEvent failed", - "seq", evt.seq, "event_type", evt.eventType, "channel_id", evt.channelID, "err", err) - continue - } - p.persisted.Add(1) - metrics.WSEventsPersisted.Add(ctx, 1) + rows = append(rows, db.PersistedEvent{ + Seq: evt.seq, + EventType: evt.eventType, + ChannelID: evt.channelID, + Payload: evt.payload, + }) + } + // One transaction per flush instead of one autocommit write per event. + // PersistEvents keeps the best-effort contract: on tx failure it retries + // per-row so a single bad event doesn't drop the batch. + persisted, err := p.store.PersistEvents(ctx, rows) + if persisted > 0 { + p.persisted.Add(uint64(persisted)) + metrics.WSEventsPersisted.Add(ctx, int64(persisted)) + } + if failed := len(batch) - persisted; failed > 0 { + p.errors.Add(uint64(failed)) //nolint:gosec // failed is non-negative + metrics.WSEventsPersistErrors.Add(ctx, int64(failed)) + slog.Warn("event persister: flush lost events", + "failed", failed, "batch", len(batch), "err", err) } batch = batch[:0] } diff --git a/Server/ws/event_pruner_test.go b/Server/ws/event_pruner_test.go index da474212..df70e2f8 100644 --- a/Server/ws/event_pruner_test.go +++ b/Server/ws/event_pruner_test.go @@ -57,6 +57,10 @@ func (f *fakeEventStore) LastCutoff() time.Time { } // Stubs for the rest of the EventStore interface — not exercised here. +func (*fakeEventStore) PersistEvents(context.Context, []db.PersistedEvent) (int, error) { + return 0, nil +} + func (*fakeEventStore) PersistEvent(context.Context, int64, string, int64, []byte) error { panic("unused") } diff --git a/Server/ws/eventstore.go b/Server/ws/eventstore.go index 7085b95c..156b67b0 100644 --- a/Server/ws/eventstore.go +++ b/Server/ws/eventstore.go @@ -13,6 +13,10 @@ import ( // abstraction was removed in D3). type EventStore interface { PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error + // PersistEvents writes a batch in one transaction, falling back to per-row + // inserts on tx failure (best-effort). Returns rows persisted and, when any + // row was lost, the first per-row error. + PersistEvents(ctx context.Context, events []db.PersistedEvent) (int, error) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index fefa4eea..61ca31aa 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -141,6 +141,16 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann } } +// RunningForTest reports whether the hub's Run loop has started. +func (h *Hub) RunningForTest() bool { + return h.running.Load() +} + +// ClientUserIDForTest returns the client's user ID for external tests. +func ClientUserIDForTest(c *Client) int64 { + return c.userID +} + // TouchForTest exposes Client.touch for external tests. func TouchForTest(c *Client) { c.touch() diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 6b8a30bf..589c9799 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -85,14 +85,10 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { reqID = reqID[:64] } - // Request-scoped logger with correlation context. - reqLog := slog.With( - "user_id", c.userID, - "msg_type", msgType, - "req_id", reqID, - ) - - reqLog.Debug("ws ← client message") + // Correlation attrs (user_id/msg_type/req_id) are inlined at each log site + // below rather than bound via slog.With — the With clone allocated a new + // handler chain per message even when nothing ended up being logged. + slog.Debug("ws ← client message", "user_id", c.userID, "msg_type", msgType, "req_id", reqID) // ── Typed command dispatch ─────────────────────────────────────────── // Every message type parses through its constructor into a typed Command, @@ -101,14 +97,14 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // migration is complete. ctor, ok := getCommandConstructor(env.Type) if !ok { - reqLog.Warn("ws handleMessage unknown type") + slog.Warn("ws handleMessage unknown type", "user_id", c.userID, "msg_type", msgType, "req_id", reqID) c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", msgType))) return } cmd, parseErr := ctor(c.userID, env.ID, env.Payload) if parseErr != nil { - reqLog.Warn("ws command parse error", "err", parseErr) + slog.Warn("ws command parse error", "user_id", c.userID, "msg_type", msgType, "req_id", reqID, "err", parseErr) c.sendMsg(buildErrorMsgWithID(ErrCodeBadRequest, "invalid payload", env.ID)) return } @@ -134,7 +130,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { if !dispatched { // A registered constructor with no V2 handler is a wiring bug — the // guard test (TestEveryConstructorHasV2Handler) locks this shut. - reqLog.Error("ws no V2 handler for constructed command", "type", env.Type) + slog.Error("ws no V2 handler for constructed command", + "user_id", c.userID, "msg_type", msgType, "req_id", reqID, "type", env.Type) c.sendMsg(buildErrorMsgWithID(ErrCodeInternal, "internal error", env.ID)) return } @@ -142,7 +139,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { if ce, ok := result.Error.(ClientError); ok { c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID)) } else { - reqLog.Error("ws handler internal error", "err", result.Error) + slog.Error("ws handler internal error", + "user_id", c.userID, "msg_type", msgType, "req_id", reqID, "err", result.Error) c.sendMsg(buildErrorMsgWithID(ErrCodeInternal, "internal error", env.ID)) } // A rejection may still need to evict: voice_token_refresh returns @@ -210,6 +208,13 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // connection — including the SPEAK/VIDEO grants baked into a freshly minted // LiveKit token — instead of persisting until the user reconnects. This mirrors // the V2 handlers, which already resolve the live role (deps.go). +// +// Deliberately NOT routed through the cached PermissionService: the only +// production caller is sweepStaleVoiceStates, the last-line revocation backstop +// that evicts live voice participants. Reading the DB live keeps that backstop +// authoritative even for a permission change that somehow bypassed the +// invalidation hooks, and the sweep runs once a minute for only the clients +// currently in voice, so the uncached cost is negligible. func (h *Hub) hasChannelPerm(ctx context.Context, c *Client, channelID int64, perm int64) bool { role, err := h.db.GetRoleForUser(ctx, c.userID) if err != nil || role == nil { @@ -227,7 +232,7 @@ func (h *Hub) hasChannelPerm(ctx context.Context, c *Client, channelID int64, pe // is what a channel id taken straight from a client frame requires: role bits // alone let any member through to a DM they are not a participant of. func (h *Hub) requireChannelAccess(ctx context.Context, c *Client, channelID int64, perm int64, permLabel string) bool { - if hasChannelAccess(ctx, h.db, h.permChecker, c.userID, channelID, perm) { + if hasChannelAccess(ctx, h.db, h.permChecker, h.perms, c.userID, channelID, perm) { return true } slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 70b8481c..5910ae21 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -59,11 +59,14 @@ func handleChatSendV2(ctx context.Context, cmd Command, info ClientInfo, deps an // DM path: build dm_channel_open events + sequenced message. var events []Event - if result.SenderUser != nil { + if result.SenderUser != nil && len(result.OpenedDMFor) > 0 { + // The payload is identical for every recipient, so marshal it once + // outside the loop (delivery wraps it per-send without mutating it). + openPayload := buildDMChannelOpen(sendCmd.ChannelID(), result.SenderUser) for _, pid := range result.OpenedDMFor { events = append(events, DMChannelOpenEvent{ targetUserID: pid, - payload: buildDMChannelOpen(sendCmd.ChannelID(), result.SenderUser), + payload: openPayload, }) } } diff --git a/Server/ws/handlers_ping.go b/Server/ws/handlers_ping.go index 66742474..66d110ba 100644 --- a/Server/ws/handlers_ping.go +++ b/Server/ws/handlers_ping.go @@ -2,15 +2,16 @@ package ws import ( "context" - "fmt" "time" + + "github.com/owncord/server/auth" ) // handlePingV2 is the V2 handler for ping (heartbeat) messages. // It rate-limits and returns a pong reply on success. func handlePingV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PingDeps) - if d.Limiter != nil && !d.Limiter.Allow(fmt.Sprintf("ping:%d", info.UserID), 2, time.Second) { + if d.Limiter != nil && !d.Limiter.Allow(auth.Key("ping", info.UserID), 2, time.Second) { return Result{} // rate limited: silent drop } return Result{Reply: buildJSON(map[string]any{"type": MsgTypePong})} diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 9896d35d..665ab7a4 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -202,14 +202,14 @@ func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Trigger the expiry check by sending enough messages to cross the check threshold. for i := range ws.SessionCheckInterval + 1 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) } - time.Sleep(100 * time.Millisecond) + // The session check (and any kick) runs synchronously inside handleMessage. // Client should still be registered. if hub.ClientCount() == 0 { t.Error("client was removed despite having a valid session") @@ -240,14 +240,14 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Trigger the expiry check. for range ws.SessionCheckInterval + 1 { // Use a harmless but parseable message to accumulate message count. hub.HandleMessageForTest(c, []byte(`{"type":"presence_update","payload":{"status":"online"}}`)) } - time.Sleep(100 * time.Millisecond) + // The expiry check kicks synchronously (kickClient) inside handleMessage. // The client's send channel should be closed (connection severed). // We verify this by checking that the send channel has been closed, @@ -261,7 +261,6 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { } // The most reliable assertion: hub should have unregistered the client. - time.Sleep(50 * time.Millisecond) if hub.ClientCount() != 0 { t.Error("expired-session client was not removed from the hub") } @@ -279,13 +278,13 @@ func TestSessionExpiry_MissingTokenHashSkipsCheck(t *testing.T) { // No token hash — simulates old-style test clients. c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send past the threshold; should not panic or remove the client. + // The session check runs synchronously inside handleMessage. for i := range ws.SessionCheckInterval + 1 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) } - time.Sleep(100 * time.Millisecond) if hub.ClientCount() == 0 { t.Error("client without token hash was incorrectly removed") @@ -304,16 +303,15 @@ func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send 3 messages in quick succession. for i := range 3 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("rapid %d", i))) } - time.Sleep(50 * time.Millisecond) // Drain all messages. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -339,16 +337,14 @@ func TestSlowMode_EnforcedAfterFirstMessage(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // First message should succeed. hub.HandleMessageForTest(c, chatSendMsg(chID, "first message")) - time.Sleep(30 * time.Millisecond) - drainChan(send) // clear the ack + drainChanTimeout(send, 30*time.Millisecond) // clear the ack // Second message within slow_mode window should be rejected. hub.HandleMessageForTest(c, chatSendMsg(chID, "second message too soon")) - time.Sleep(30 * time.Millisecond) code := receiveErrorCode(send, 200*time.Millisecond) if code != "SLOW_MODE" { @@ -371,17 +367,15 @@ func TestSlowMode_DifferentUsersNotBlocked(t *testing.T) { cB := ws.NewTestClientWithUser(hub, userB, chID, sendB) hub.Register(cA) hub.Register(cB) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, cB) hub.HandleMessageForTest(cA, chatSendMsg(chID, "from A")) - time.Sleep(20 * time.Millisecond) // B sends after A — B's slow mode window is independent. hub.HandleMessageForTest(cB, chatSendMsg(chID, "from B")) - time.Sleep(50 * time.Millisecond) // B should NOT receive a SLOW_MODE error. - msgs := drainChan(sendB) + msgs := drainChanTimeout(sendB, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -407,17 +401,15 @@ func TestSlowMode_ModeratorBypassesSlowMode(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, mod, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send two messages in rapid succession — mod should not be blocked. hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 1")) - time.Sleep(20 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 20*time.Millisecond) hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 2")) - time.Sleep(50 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -454,21 +446,19 @@ func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) { cB := ws.NewTestClientWithUser(hub, user, chB, sendB) hub.Register(cA) - time.Sleep(10 * time.Millisecond) + waitRegistered(t, hub, cA) // cA sends in channel A — triggers slow mode for A. hub.HandleMessageForTest(cA, chatSendMsg(chA, "msg in A")) - time.Sleep(20 * time.Millisecond) - drainChan(sendA) + drainChanTimeout(sendA, 20*time.Millisecond) // Now send in channel B via cB — should NOT be affected. hub.Register(cB) - time.Sleep(10 * time.Millisecond) + waitRegistered(t, hub, cB) hub.HandleMessageForTest(cB, chatSendMsg(chB, "msg in B")) - time.Sleep(50 * time.Millisecond) - msgs := drainChan(sendB) + msgs := drainChanTimeout(sendB, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -524,11 +514,10 @@ func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send a message with attachments — should be rejected before persisting. hub.HandleMessageForTest(c, chatSendMsgWithAttachments(chID, "has attachment", []string{"fake-attach-id"})) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { @@ -557,16 +546,14 @@ func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // First message to prime the window. hub.HandleMessageForTest(c, chatSendMsg(chID, "first")) - time.Sleep(20 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 20*time.Millisecond) // Second message — should receive SLOW_MODE error with duration in message. hub.HandleMessageForTest(c, chatSendMsg(chID, "too soon")) - time.Sleep(50 * time.Millisecond) timer := time.NewTimer(300 * time.Millisecond) defer timer.Stop() @@ -624,14 +611,13 @@ func TestChatSend_InvalidPayload_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_send", "payload": "not-an-object", }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -648,7 +634,7 @@ func TestChatSend_InvalidChannelID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_send", @@ -658,7 +644,6 @@ func TestChatSend_InvalidChannelID_ReturnsBadRequest(t *testing.T) { }, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -675,10 +660,9 @@ func TestChatSend_ChannelNotFound_ReturnsNotFound(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 99999, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatSendMsg(99999, "hello")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "NOT_FOUND" { @@ -696,11 +680,10 @@ func TestChatSend_EmptyContent_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send message with empty content. hub.HandleMessageForTest(c, chatSendMsg(chID, "")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -718,7 +701,7 @@ func TestChatSend_TooLongContent_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Build a 4001-rune string to exceed the limit. longContent := make([]rune, 4001) @@ -726,7 +709,6 @@ func TestChatSend_TooLongContent_ReturnsBadRequest(t *testing.T) { longContent[i] = 'a' } hub.HandleMessageForTest(c, chatSendMsg(chID, string(longContent))) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -748,7 +730,7 @@ func TestChatSend_SuccessWithReplyTo(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_send", @@ -759,7 +741,6 @@ func TestChatSend_SuccessWithReplyTo(t *testing.T) { }, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) // Should get a chat_send_ok ack. timer := time.NewTimer(300 * time.Millisecond) @@ -795,10 +776,9 @@ func TestChatSend_NilUserClientSendsMessage(t *testing.T) { // Use NewTestClientWithUser so permissions work (user record is attached). c := ws.NewTestClientWithUser(hub, owner, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatSendMsg(chID, "hello")) - time.Sleep(50 * time.Millisecond) // Expect a chat_send_ok. timer := time.NewTimer(300 * time.Millisecond) @@ -829,16 +809,14 @@ func TestPresence_RateLimit_ReturnsError(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // First presence update — should succeed. hub.HandleMessageForTest(c, presenceUpdateMsg("online")) - time.Sleep(20 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 20*time.Millisecond) // Second presence update immediately — should be rate-limited. hub.HandleMessageForTest(c, presenceUpdateMsg("idle")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "RATE_LIMITED" { @@ -951,10 +929,9 @@ func TestChatEdit_ValidEdit_BroadcastsChatEdited(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatEditMsg(msgID, "edited content")) - time.Sleep(50 * time.Millisecond) payload := receiveMsgOfType(send, "chat_edited", 300*time.Millisecond) if payload == nil { @@ -977,7 +954,7 @@ func TestChatEdit_InvalidPayload_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send a chat_edit envelope with an unparseable payload. raw, _ := json.Marshal(map[string]any{ @@ -985,7 +962,6 @@ func TestChatEdit_InvalidPayload_ReturnsBadRequest(t *testing.T) { "payload": "not-an-object", }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1004,10 +980,9 @@ func TestChatEdit_EmptyContent_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatEditMsg(msgID, "")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1027,10 +1002,9 @@ func TestChatEdit_NotOwner_ReturnsForbidden(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, editor, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatEditMsg(msgID, "stolen edit")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { @@ -1048,7 +1022,7 @@ func TestChatEdit_InvalidMessageID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_edit", @@ -1058,7 +1032,6 @@ func TestChatEdit_InvalidMessageID_ReturnsBadRequest(t *testing.T) { }, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1079,10 +1052,9 @@ func TestChatDelete_OwnerDeletesOwn_BroadcastsChatDeleted(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) - time.Sleep(50 * time.Millisecond) payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond) if payload == nil { @@ -1106,10 +1078,9 @@ func TestChatDelete_ModeratorDeletesOthers_BroadcastsChatDeleted(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, mod, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) - time.Sleep(50 * time.Millisecond) payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond) if payload == nil { @@ -1129,10 +1100,9 @@ func TestChatDelete_NonOwnerWithoutManageMessages_ReturnsForbidden(t *testing.T) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, other, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { @@ -1150,14 +1120,13 @@ func TestChatDelete_InvalidPayload_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_delete", "payload": "bad", }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1175,10 +1144,9 @@ func TestChatDelete_NonExistentMessage_ReturnsNotFound(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatDeleteMsg(99999)) - time.Sleep(50 * time.Millisecond) // Handler returns FORBIDDEN (not NOT_FOUND) to prevent message-ID enumeration. code := receiveErrorCode(send, 300*time.Millisecond) @@ -1197,7 +1165,7 @@ func TestChatDelete_InvalidMessageID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "chat_delete", @@ -1206,7 +1174,6 @@ func TestChatDelete_InvalidMessageID_ReturnsBadRequest(t *testing.T) { }, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1225,13 +1192,12 @@ func TestChatEdit_RateLimit_ReturnsError(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Exhaust the rate limit (chatRateLimit = 10 per second). for i := range 11 { hub.HandleMessageForTest(c, chatEditMsg(msgID, fmt.Sprintf("edit-%d", i))) } - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "RATE_LIMITED" { @@ -1255,13 +1221,12 @@ func TestChatDelete_RateLimit_ReturnsError(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Exhaust the rate limit (chatRateLimit = 10 per second). for _, id := range msgIDs { hub.HandleMessageForTest(c, chatDeleteMsg(id)) } - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "RATE_LIMITED" { @@ -1285,10 +1250,9 @@ func TestChatEdit_DeletedMessage_ReturnsForbidden(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, chatEditMsg(msgID, "ghost edit")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "FORBIDDEN" { @@ -1309,10 +1273,9 @@ func TestReaction_AddReaction_BroadcastsReactionUpdate(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "👍")) - time.Sleep(50 * time.Millisecond) payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond) if payload == nil { @@ -1339,10 +1302,9 @@ func TestReaction_RemoveReaction_BroadcastsReactionUpdate(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_remove", msgID, "❤️")) - time.Sleep(50 * time.Millisecond) payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond) if payload == nil { @@ -1363,14 +1325,13 @@ func TestReaction_InvalidPayload_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "reaction_add", "payload": "bad", }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1389,10 +1350,9 @@ func TestReaction_EmptyEmoji_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1411,12 +1371,11 @@ func TestReaction_TooLongEmoji_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // 33-character emoji string — exceeds the 32-byte limit. longEmoji := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 33 chars hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, longEmoji)) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1435,10 +1394,9 @@ func TestReaction_ControlCharInEmoji_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "a\x01b")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1456,10 +1414,9 @@ func TestReaction_NonExistentMessage_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", 99999, "👍")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1478,16 +1435,14 @@ func TestReaction_DuplicateAdd_ReturnsConflict(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // First add — should succeed. hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥")) - time.Sleep(30 * time.Millisecond) - drainChan(send) // clear the first broadcast + drainChanTimeout(send, 30*time.Millisecond) // clear the first broadcast // Second add of the same emoji — should fail with CONFLICT. hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "CONFLICT" { @@ -1505,10 +1460,9 @@ func TestReaction_InvalidMessageID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", 0, "👍")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1532,10 +1486,9 @@ func TestReaction_DeletedMessage_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "👍")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1580,11 +1533,10 @@ func TestTyping_ValidTyping_BroadcastsToOthers(t *testing.T) { t.Fatalf("hub did not register both clients within timeout (count=%d)", hub.ClientCount()) } hub.HandleMessageForTest(cSender, typingStartMsg(chID)) - time.Sleep(50 * time.Millisecond) // Watcher should receive a "typing" broadcast (the outbound event type from // buildTypingMsg is "typing", distinct from the inbound "typing_start"). - watcherMsgs := drainChan(sendWatcher) + watcherMsgs := drainChanTimeout(sendWatcher, 50*time.Millisecond) foundTyping := false for _, m := range watcherMsgs { var env map[string]any @@ -1622,10 +1574,9 @@ func TestTyping_InvalidChannelID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, typingStartMsg(0)) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1656,15 +1607,13 @@ func TestTyping_RateLimited_SilentlyDropped(t *testing.T) { // First typing event — should go through. hub.HandleMessageForTest(cSender, typingStartMsg(chID)) - time.Sleep(30 * time.Millisecond) - drainChan(sendWatcher) + drainChanTimeout(sendWatcher, 30*time.Millisecond) // Second typing event immediately — should be silently dropped. hub.HandleMessageForTest(cSender, typingStartMsg(chID)) - time.Sleep(50 * time.Millisecond) // Sender should NOT receive an error (silently dropped). - senderMsgs := drainChan(sendSender) + senderMsgs := drainChanTimeout(sendSender, 50*time.Millisecond) for _, m := range senderMsgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -1723,28 +1672,16 @@ func TestBroadcastExclude_SendsToOthersNotSelf(t *testing.T) { // u1 sends a typing event — should reach u2 and u3 but NOT u1. hub.HandleMessageForTest(c1, typingStartMsg(chID)) - time.Sleep(50 * time.Millisecond) // u2 and u3 must receive the "typing" broadcast. for i, sendCh := range []<-chan []byte{send2, send3} { - msgs := drainChan(sendCh) - found := false - for _, m := range msgs { - var env map[string]any - if err := json.Unmarshal(m, &env); err != nil { - continue - } - if env["type"] == "typing" { - found = true - break - } - } - if !found { + if receiveMsgOfType(sendCh, "typing", waitTimeout) == nil { t.Errorf("user%d (non-sender) did not receive typing broadcast", i+2) } } - // u1 (sender) must NOT receive it. + // u1 (sender) must NOT receive it. The fan-out that reached u2 and u3 + // has completed, so a wrongly-included copy would already be buffered. msgs1 := drainChan(send1) for _, m := range msgs1 { var env map[string]any @@ -1781,9 +1718,8 @@ func TestBroadcastExclude_DifferentChannelNotReceived(t *testing.T) { // uA types in channel A — uB in channel B must NOT receive it. hub.HandleMessageForTest(cA, typingStartMsg(chA)) - time.Sleep(50 * time.Millisecond) - msgsB := drainChan(sendB) + msgsB := drainChanTimeout(sendB, 50*time.Millisecond) for _, m := range msgsB { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -1806,10 +1742,9 @@ func TestPresence_InvalidStatus_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, presenceUpdateMsg("invisible")) - time.Sleep(50 * time.Millisecond) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { @@ -1829,13 +1764,12 @@ func TestPresence_ValidStatus_Broadcasts(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, presenceUpdateMsg(status)) - time.Sleep(50 * time.Millisecond) // Must NOT receive a BAD_REQUEST error. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -1867,7 +1801,7 @@ func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) { // Start client on channel 0. c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Focus on chID. raw, _ := json.Marshal(map[string]any{ @@ -1875,10 +1809,9 @@ func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) { "payload": map[string]any{"channel_id": chID}, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) // No error expected. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) for _, m := range msgs { var env map[string]any if err := json.Unmarshal(m, &env); err != nil { @@ -1891,20 +1824,8 @@ func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) { // Now broadcast to chID — client should receive it because channel was focused. hub.BroadcastToChannel(chID, []byte(`{"type":"ping","payload":{}}`)) - time.Sleep(30 * time.Millisecond) - found := false - for _, m := range drainChan(send) { - var env map[string]any - if err := json.Unmarshal(m, &env); err != nil { - continue - } - if env["type"] == "ping" { - found = true - break - } - } - if !found { + if receiveMsgOfType(send, "ping", waitTimeout) == nil { t.Error("client did not receive broadcast after channel_focus updated its channelID") } } @@ -1918,17 +1839,16 @@ func TestChannelFocus_InvalidChannelID_ReturnsBadRequest(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw, _ := json.Marshal(map[string]any{ "type": "channel_focus", "payload": map[string]any{"channel_id": 0}, }) hub.HandleMessageForTest(c, raw) - time.Sleep(50 * time.Millisecond) // Constructor rejects channel_id <= 0 with BAD_REQUEST. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) found := false for _, m := range msgs { var env map[string]any @@ -1975,16 +1895,14 @@ func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Send enough messages to cross the session-check threshold. for i := range ws.SessionCheckInterval + 1 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) } - time.Sleep(100 * time.Millisecond) - // The hub should have kicked the banned client. - time.Sleep(50 * time.Millisecond) + // The ban check kicks synchronously (kickClient) inside handleMessage. if hub.ClientCount() != 0 { t.Error("banned user was not kicked after session check") } diff --git a/Server/ws/handlers_voice.go b/Server/ws/handlers_voice.go index 5c5a076c..ce98ea09 100644 --- a/Server/ws/handlers_voice.go +++ b/Server/ws/handlers_voice.go @@ -2,7 +2,8 @@ package ws import ( "context" - "fmt" + + "github.com/owncord/server/auth" ) // registerVoiceControlsV2 registers all voice V2 handlers: the control toggles, @@ -40,7 +41,7 @@ func handleVoiceJoinV2(_ context.Context, _ Command, _ ClientInfo, _ any) Result // then hands off to the hub's handleVoiceLeave routine via the applier. func handleVoiceLeaveV2(_ context.Context, cmd Command, _ ClientInfo, deps any) Result { d := deps.(VoiceDeps) - ratKey := fmt.Sprintf("voice_leave:%d", cmd.UserID()) + ratKey := auth.Key("voice_leave", cmd.UserID()) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice leave attempts"}} } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 1307e27e..4b2a71e2 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -2,9 +2,7 @@ package ws import ( - "bytes" "context" - "fmt" "log/slog" "sync" "sync/atomic" @@ -19,18 +17,6 @@ import ( "github.com/owncord/server/syncutil" ) -// broadcastMsg is an internal message queued for delivery. -type broadcastMsg struct { - channelID int64 // 0 = send to all connected clients - msg []byte - // recipients, when non-nil, replaces topic fan-out with direct delivery to - // exactly these user IDs. Used by voice_state/voice_leave: they are global - // in scope (every sidebar shows them) but must not disclose a channel the - // recipient's role may not READ, and the audience is resolved off the hub - // goroutine so deliverBroadcast stays free of permission queries. - recipients []int64 -} - // Hub manages all active WebSocket clients and routes messages between them. // All exported methods are safe to call from multiple goroutines. type Hub struct { @@ -53,6 +39,12 @@ type Hub struct { lkProcess *LiveKitProcess registry *HandlerRegistry permChecker *permissions.Checker + // perms is the cached permission service (service.PermissionService). Nil in + // bare test hubs constructed without Services; every use falls back to the + // live permChecker path then. Revocation stays prompt because each mutation + // site invalidates synchronously (InvalidateUser on role change, + // InvalidateAll on channel-override change) — the cache TTL is a backstop. + perms *service.PermissionService // messageSvc gates plugin broadcasts through the same posting policy as a // real message send (permissions, DM membership, DM blocks). Nil only in // bare test hubs; the broadcast gate fails closed then. @@ -80,6 +72,12 @@ type Hub struct { // call fails loudly instead of racing the dispatch loop. running atomic.Bool + // In-flight guards for the DB-heavy sweeps Run kicks off in their own + // goroutines (startSweep): a tick that arrives while the previous sweep + // is still running is skipped rather than stacked. + sessionSweepInFlight atomic.Bool + voiceSweepInFlight atomic.Bool + // Phase B Step 7 — reconnection tier metrics. Incremented per resume. reconnectTierBuf atomic.Uint64 reconnectTierDB atomic.Uint64 @@ -143,6 +141,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages h.messageSvc = svc.Messages + h.perms = svc.Permissions } registerChatHandlers(reg, chatDeps) @@ -158,6 +157,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * DB: h.db, Limiter: h.limiter, Permissions: h.permChecker, + PermSvc: h.perms, LiveKit: h.livekit, TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit) KeyHolder: h, @@ -206,53 +206,6 @@ func (h *Hub) refreshSettingsLocked(ctx context.Context) { h.settingsLastUpdate = time.Now() } -// SetLiveKit sets the LiveKit client on the hub. Must be called before Run; -// late calls are ignored with an error log. -func (h *Hub) SetLiveKit(lk *LiveKitClient) { - if h.rejectIfRunning("SetLiveKit") { - return - } - h.livekit = lk -} - -// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit -// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed -// as a dep at registration time (before SetLiveKit is called). -func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) { - if h.livekit == nil { - return "", fmt.Errorf("voice not configured") - } - return h.livekit.GenerateToken(userID, username, channelID, voiceJoinToken, canPublish, canSubscribe, canVideo, canScreenShare) -} - -// URL delegates to the LiveKit client. Returns empty string if not configured. -func (h *Hub) URL() string { - if h.livekit == nil { - return "" - } - return h.livekit.URL() -} - -// LiveKitHealthCheck probes the LiveKit server for connectivity. -// It tries the SDK client first (ListRooms), and falls back to an HTTP probe -// if a managed process is configured. Returns false with a reason if LiveKit -// is not configured or unreachable. -func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) { - if h.livekit == nil { - return false, fmt.Errorf("not configured") - } - return h.livekit.HealthCheck(ctx) -} - -// SetLiveKitProcess sets the LiveKit process manager on the hub. Must be -// called before Run; late calls are ignored with an error log. -func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) { - if h.rejectIfRunning("SetLiveKitProcess") { - return - } - h.lkProcess = p -} - // Run starts the hub's dispatch loop. It blocks until Stop is called. // Must be called in its own goroutine. // @@ -313,9 +266,12 @@ func (h *Hub) Run() { case <-staleTicker.C: h.sweepStaleClients() case <-sessionSweepTicker.C: - h.sweepRevokedSessions() + // The revoked-session and stale-voice sweeps do per-client + // DB work, so they run off the dispatch goroutine — a slow + // sweep must not stall broadcast delivery. + h.startSweep(&h.sessionSweepInFlight, h.sweepRevokedSessions) case <-voiceSweepTicker.C: - h.sweepStaleVoiceStates() + h.startSweep(&h.voiceSweepInFlight, h.sweepStaleVoiceStates) } } }() @@ -365,46 +321,6 @@ func (h *Hub) GracefulStop() { }) } -// CleanupVoiceForChannel removes all voice participants from the given channel. -// Called when a channel is deleted. -func (h *Hub) CleanupVoiceForChannel(channelID int64) { - // Cleanup must complete even if the triggering request goes away. - ctx := context.Background() - // Get all users in the channel's voice state from DB. - states, err := h.db.GetChannelVoiceStates(ctx, channelID) - if err != nil { - slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID) - return - } - if len(states) == 0 { - return - } - - // Clean up DB state and LiveKit for each participant. - 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) - } - - // Clear client voice state. - h.mu.RLock() - if client, ok := h.clients[vs.UserID]; ok { - client.clearVoiceChID() - } - h.mu.RUnlock() - - // Remove from LiveKit (best-effort). - if h.livekit != nil { - _ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt) - } - } - - // Broadcast voice_leave for each participant. - for _, vs := range states { - h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, vs.UserID)) - } -} - // 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 { @@ -506,398 +422,6 @@ func (h *Hub) unregisterNow(c *Client) bool { return true // different client registered = was replaced } -// BroadcastToChannel enqueues msg for delivery to all clients subscribed to -// channelID. When channelID is 0 the message is sent to every connected client. -// Non-blocking: if the broadcast channel is full the message is dropped with a warning. -func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) { - select { - case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}: - default: - h.broadcastDrops.Add(1) - slog.Warn("hub: broadcast channel full, dropping message", - "channel_id", channelID, "msg_len", len(msg)) - } -} - -// BroadcastToAll enqueues msg for delivery to every connected client. -// Non-blocking: if the broadcast channel is full the message is dropped with a warning. -func (h *Hub) BroadcastToAll(msg []byte) { - select { - case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}: - default: - h.broadcastDrops.Add(1) - slog.Warn("hub: broadcast channel full, dropping global message", - "msg_len", len(msg)) - } -} - -// broadcastVoiceEvent enqueues a voice_state / voice_leave message for the -// connected clients whose current role may READ channelID. -// -// These events used to go out via BroadcastToAll, which handed every -// authenticated client the membership and camera/mute state of voice channels -// that channel_overrides hides from their role — while the equivalent read path -// (buildReady) deliberately filters voice states to readable channels. Tagging -// the event with its real channel id also makes reconnect replay filter it, -// where a channelID of 0 was replayed unconditionally. -// -// 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") -} - -// broadcastChannelScoped enqueues msg for exactly the connected clients whose -// current role may READ channelID, tagged with that channel id so reconnect -// replay filters it too (EventsSinceFiltered replays a channelID of 0 -// unconditionally). kind only labels the drop warning. -func (h *Hub) broadcastChannelScoped(ctx context.Context, channelID int64, msg []byte, kind string) { - bm := broadcastMsg{ - channelID: channelID, - msg: msg, - recipients: h.channelReadAudience(ctx, channelID), - } - select { - case h.broadcast <- bm: - default: - h.broadcastDrops.Add(1) - slog.Warn("hub: broadcast channel full, dropping "+kind, - "channel_id", channelID, "msg_len", len(msg)) - } -} - -// channelReadAudience returns the connected user IDs whose current role may READ -// channelID. Always non-nil, so an empty result means "deliver to nobody" -// rather than "no filter". Roles are resolved per client (an admin may have -// reassigned one mid-session) and the channel verdict is memoised per role, so -// the cost is one role lookup per connected client plus one override lookup per -// distinct role. Fails closed: a client whose role cannot be resolved is left -// out. Mirrors RefreshChannelVisibility, which resolves visibility the same way. -func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 { - h.mu.RLock() - userIDs := make([]int64, 0, len(h.clients)) - for uid := range h.clients { - userIDs = append(userIDs, uid) - } - h.mu.RUnlock() - - audience := make([]int64, 0, len(userIDs)) - if h.db == nil || h.permChecker == nil { - return audience - } - visibleByRole := make(map[int64]bool) - for _, uid := range userIDs { - role, err := h.db.GetRoleForUser(ctx, uid) - if err != nil || role == nil { - continue - } - visible, ok := visibleByRole[role.ID] - if !ok { - visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, permissions.ReadMessages) - visibleByRole[role.ID] = visible - } - if visible { - audience = append(audience, uid) - } - } - return audience -} - -// BroadcastServerRestart sends a server_restart message to all connected clients. -// reason describes why the server is restarting (e.g., "update"). -// delaySeconds tells clients how long until the server actually shuts down. -func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) { - h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds)) -} - -// BroadcastChannelCreate sends a channel_create message to the connected -// clients whose current role may READ ch. It used to go out via BroadcastToAll, -// which handed every authenticated client the name, category and topic of a -// channel that channel_overrides hides from their role — metadata the ready -// payload (buildReady/VisibleChannelIDs) deliberately withholds. -// -// The admin HubBroadcaster interface carries no context, so — like -// RefreshChannelVisibility — the audience is resolved against Background: the -// fan-out must complete regardless of the triggering request. -func (h *Hub) BroadcastChannelCreate(ch *db.Channel) { - h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelCreate(ch), "channel_create") -} - -// BroadcastChannelUpdate sends a channel_update message to the connected -// clients whose current role may READ ch. Same disclosure as -// BroadcastChannelCreate; same filtered fan-out. -func (h *Hub) BroadcastChannelUpdate(ch *db.Channel) { - h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelUpdate(ch), "channel_update") -} - -// BroadcastChannelDelete sends a channel_delete message to all connected clients. -// -// Deliberately unfiltered: the payload is the bare channel id, with none of the -// metadata create/update carry, and by the time the admin handler calls this the -// channel row — and with it the ON DELETE CASCADE'd channel_overrides — is -// already gone, so a permission check here would answer from base role perms -// and could drop the delete for exactly the users who saw the channel via a -// positive override, stranding it in their sidebar. -func (h *Hub) BroadcastChannelDelete(channelID int64) { - h.BroadcastToAll(buildChannelDelete(channelID)) -} - -// RefreshChannelVisibility re-evaluates which connected clients may see ch -// after a channel_overrides change and sends targeted channel_create / -// channel_delete messages so sidebars converge without a reconnect. Clients -// that lose visibility are also unsubscribed from the channel topic and have -// their focused channel cleared so live messages stop flowing. -// -// The sends deliberately bypass the sequenced broadcast/replay path: a -// replayed channel_delete would be filtered by the allowed-channel set -// computed at replay time, which after an override change is exactly the -// inverse of the intended audience. Clients tolerate seq-less messages. -func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { - if ch == nil { - return - } - - h.mu.RLock() - clients := make([]*Client, 0, len(h.clients)) - for _, c := range h.clients { - clients = append(clients, c) - } - h.mu.RUnlock() - - // Called via the admin HubBroadcaster interface, which carries no context; - // the targeted re-sync must complete regardless of the triggering request. - ctx := context.Background() - - // Visibility is a function of the role, so resolve each role once. - visibleByRole := make(map[int64]bool) - roleVisible := func(roleID int64) bool { - if v, ok := visibleByRole[roleID]; ok { - return v - } - visible := false - role, err := h.db.GetRoleByID(ctx, roleID) - if err == nil && role != nil { - // Single visibility predicate shared with buildReady / REST - // ListVisibleChannels; the checker fails closed on a lookup error - // and bypasses for admins, matching the other sites exactly. - visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages) - } - visibleByRole[roleID] = visible - return visible - } - - for _, c := range clients { - if c.user == nil { - continue - } - // c.user is a connect-time snapshot; an admin may have changed the - // user's role mid-session, so resolve the current role from the DB. - // Fail closed: on error send nothing rather than mis-target. - fresh, err := h.db.GetUserByID(ctx, c.user.ID) - if err != nil || fresh == nil { - slog.Warn("hub: RefreshChannelVisibility could not resolve user role", - "user_id", c.user.ID, "err", err) - continue - } - if roleVisible(fresh.RoleID) { - // Idempotent add on the client; also refreshes channel metadata. - c.sendMsg(buildChannelCreate(ch)) - continue - } - c.sendMsg(buildChannelDelete(ch.ID)) - h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID)) - c.mu.Lock() - if c.channelID == ch.ID { - c.channelID = 0 - } - c.mu.Unlock() - } - - // 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)) -} - -// mustFullResync reports whether a client resuming from lastSeq predates the -// most recent channel-visibility change and therefore cannot converge via -// replay. -func (h *Hub) mustFullResync(lastSeq uint64) bool { - w := h.visibilityChangeSeq.Load() - return w > 0 && lastSeq <= w -} - -// BroadcastMemberBan sends a member_ban message to all connected clients -// and immediately disconnects the banned user's WebSocket connection (BUG-113). -func (h *Hub) BroadcastMemberBan(userID int64) { - h.BroadcastToAll(buildMemberBan(userID)) - h.DisconnectUser(userID) -} - -// DisconnectUser forcibly disconnects the client identified by userID. -// No-op if the user is not currently connected. -func (h *Hub) DisconnectUser(userID int64) { - h.mu.RLock() - c, ok := h.clients[userID] - h.mu.RUnlock() - if !ok { - return - } - slog.Info("hub: disconnecting user", "user_id", userID) - c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned")) - h.kickClient(c) -} - -// BroadcastUserUpdate sends a user_update message to all connected clients -// when a user changes their profile (username, avatar, identity key). -func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) { - h.BroadcastToAll(buildUserUpdate(userID, username, avatar, identityPublicKey)) -} - -// BroadcastMemberUpdate sends a member_update message to all connected clients -// and re-evaluates the reassigned user's live channel subscriptions. -func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { - h.BroadcastToAll(buildMemberUpdate(userID, roleName)) - h.revokeUnreadableChannels(userID) -} - -// revokeUnreadableChannels drops the channel-topic subscriptions the user's new -// role may no longer READ. READ_MESSAGES is checked once, at channel_focus, and -// then becomes a durable pub/sub subscription, so without this a demoted user -// keeps receiving every chat_message / chat_edited / reaction_update posted in -// the channels their old role could read for as long as the socket stays open. -// -// The per-client work mirrors RefreshChannelVisibility, the channel_overrides -// equivalent: targeted, unsequenced channel_delete + Unsubscribe (a replayed -// channel_delete would be filtered by the allowed set computed at replay time), -// then a visibilityChangeSeq bump so a client resuming across this change takes -// the full-ready path instead of replay. -// -// 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)) - - if h.db == nil { - return - } - h.mu.RLock() - c, ok := h.clients[userID] - h.mu.RUnlock() - if !ok || c.user == nil { - return - } - - // Called via the admin HubBroadcaster interface, which carries no context; - // the re-evaluation must complete regardless of the triggering request. - ctx := context.Background() - - // c.user is a connect-time snapshot and the role just changed, so resolve - // the current user — and through it the current role — from the DB. - var allowed map[int64]bool - user, err := h.db.GetUserByID(ctx, userID) - if err == nil && user != nil { - // Same predicate as the ready payload and reconnect replay filtering. - allowed, err = h.computeAllowedChannels(ctx, h.db, user) - } - if err != nil || user == nil { - // Visibility unresolved. Keeping the old subscriptions would leak, and - // revoking them all would hollow out a sidebar the user may still be - // entitled to, so close the socket instead: the client reconnects and - // rebuilds from a ready payload computed with the new role. kickClient - // rather than DisconnectUser — the latter sends a BANNED error, which - // makes the client clear its credentials instead of reconnecting. - slog.Warn("hub: role change visibility unresolved, closing socket", - "user_id", userID, "err", err) - h.kickClient(c) - return - } - - for _, topic := range h.pubsub.TopicsForClient(userID) { - chID := channelTopicID(topic) - if chID == 0 || allowed[chID] { - continue - } - // DM access is gated on dm_participants, which no role change can - // alter, while allowed sources DMs from dm_open_state — a DM the user - // has closed (or every DM, if the DM lookup inside - // computeAllowedChannels failed) is missing from allowed even though - // its subscription is still legitimate. Never revoke a DM topic here; - // on a lookup error close the socket rather than guess. - ch, chErr := h.db.GetChannel(ctx, chID) - if chErr != nil { - slog.Warn("hub: role change channel lookup failed, closing socket", - "user_id", userID, "channel_id", chID, "err", chErr) - h.kickClient(c) - return - } - if ch != nil && ch.Type == "dm" { - continue - } - c.sendMsg(buildChannelDelete(chID)) - h.pubsub.Unsubscribe(c, topic) - c.mu.Lock() - if c.channelID == chID { - c.channelID = 0 - } - c.mu.Unlock() - } -} - -// SendToUser delivers msg directly to the client identified by userID. -// Returns true if the client was found and the message was queued. -func (h *Hub) SendToUser(userID int64, msg []byte) bool { - h.mu.RLock() - c, ok := h.clients[userID] - h.mu.RUnlock() - if !ok { - return false - } - return c.trySendMsg(msg) -} - -// SendToUserHigh sends a high-priority message to a specific user. -func (h *Hub) SendToUserHigh(userID int64, msg []byte) bool { - h.mu.RLock() - c, ok := h.clients[userID] - h.mu.RUnlock() - if !ok { - return false - } - c.sendHighMsg(msg) - return true -} - -// BroadcastToAllLow enqueues a low-priority global broadcast. -// Low-priority messages are silently dropped if a client's buffer is full. -func (h *Hub) BroadcastToAllLow(msg []byte) { - // Low-priority global broadcasts bypass the sequenced broadcast channel - // and go directly through pub/sub — they don't need replay or seq numbering. - h.pubsub.PublishGlobalLow(msg) -} - -// sendSequencedToUsersHigh 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) { - h.seqMu.Lock() - defer h.seqMu.Unlock() - - seq := h.nextSeq() - wrapped := wrapWithSeq(msg, seq) - h.replayBuf.Push(seq, channelID, wrapped) - h.persistEvent(seq, channelID, wrapped) - - for _, userID := range userIDs { - h.SendToUserHigh(userID, wrapped) - } -} - // ClientCount returns the number of currently registered clients (test helper). func (h *Hub) ClientCount() int { h.mu.RLock() @@ -924,82 +448,6 @@ func (h *Hub) VoiceSessionCount() int { return count } -// 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. -func (h *Hub) kickClient(c *Client) { - h.mu.Lock() - if current, ok := h.clients[c.userID]; ok && current == c { - delete(h.clients, c.userID) - } - h.mu.Unlock() - h.pubsub.UnsubscribeAll(c) - c.closeSend() -} - -// nextSeq returns the next monotonic sequence number for broadcast messages. -func (h *Hub) nextSeq() uint64 { - return atomic.AddUint64(&h.seq, 1) -} - -// ReplayBuffer returns the hub's event ring buffer for reconnection replay. -func (h *Hub) ReplayBuffer() *EventRingBuffer { - return h.replayBuf -} - -// SeedSeq sets the hub's monotonic sequence counter to seed (atomic). Used -// at startup to align in-memory seqs with the persisted MAX(events.seq) so -// wrapped-payload seqs stay monotonic across restarts. Calling SeedSeq with -// a value less than the current seq is a no-op (we never go backwards). -func (h *Hub) SeedSeq(seed uint64) { - for { - cur := atomic.LoadUint64(&h.seq) - if seed <= cur { - return - } - if atomic.CompareAndSwapUint64(&h.seq, cur, seed) { - return - } - } -} - -// SetEventPersister attaches a persister so subsequent broadcasts are also -// written to the persistent EventStore. Pass nil to disable. Safe to call -// at any time, including after Run has started. -func (h *Hub) SetEventPersister(p *EventPersister) { - h.eventPersister.Store(p) -} - -// SetEventStore attaches a read-side EventStore used by the cold-tier -// reconnect replay path. Typically the same store backing SetEventPersister. -// Pass nil to disable. Safe to call at any time, including after Run has -// started. -func (h *Hub) SetEventStore(s EventStore) { - if s == nil { - h.eventStore.Store(nil) - return - } - h.eventStore.Store(&s) -} - -// SetPluginRegistry wires the plugin.Registry so the hub can dispatch -// slash commands (chat_command messages) to plugin-owned handlers. -// Pass nil to disable plugin command dispatch. Must be called before Run; -// late calls are ignored with an error log. -func (h *Hub) SetPluginRegistry(r *plugin.Registry) { - if h.rejectIfRunning("SetPluginRegistry") { - return - } - h.pluginRegistry = r -} - -// SetPluginEventSink wires the plugin.EventSink so the hub fans out each -// sequenced broadcast to subscribed plugins. Pass nil to disable. Safe to -// call at any time, including after Run has started. -func (h *Hub) SetPluginEventSink(s *plugin.EventSink) { - h.pluginSink.Store(s) -} - // rejectIfRunning reports whether Run has already started, logging an error // when it has. Plain-field setters must be wired before Run: the dispatch // loop and connection goroutines read those fields without synchronization, @@ -1013,277 +461,7 @@ func (h *Hub) rejectIfRunning(setter string) bool { return false } -// ReconnectTierStats returns the per-tier resume hit counters in the order -// (buffer, db, full). Phase B Step 7 metrics surface; OpenTelemetry meters -// (Step 8) read from the same atomics. -func (h *Hub) ReconnectTierStats() (buffer, db, full uint64) { - return h.reconnectTierBuf.Load(), h.reconnectTierDB.Load(), h.reconnectTierFull.Load() -} - -// persistEvent enqueues a broadcast event for cold-storage persistence. Safe -// to call with a nil persister; never blocks the broadcast hot path. seq is -// the same hub-assigned monotonic counter embedded in payload, so the row -// written to the EventStore has a row-seq that matches the wrapped-payload -// seq the client tracks. -func (h *Hub) persistEvent(seq uint64, channelID int64, payload []byte) { - p := h.eventPersister.Load() - if p == nil { - return - } - eventType := extractEventType(payload) - if eventType == "" { - eventType = "broadcast" - if channelID != 0 { - eventType = "channel_broadcast" - } - } - p.Enqueue(int64(seq), eventType, channelID, payload) //nolint:gosec // seq is a monotonically increasing counter, never reaches MaxInt64 -} - -// extractEventType scans a wrapped JSON envelope for the value of the "type" -// field and returns it. Returns "" on any parse failure so the caller can -// substitute a generic label. The scan is intentionally not a full JSON -// decode — it only looks for the literal `"type":""` token, which -// matches every wire-format envelope produced by this server. This avoids the -// allocation cost of `encoding/json` on the broadcast hot path. -func extractEventType(payload []byte) string { - const needle = `"type":"` - idx := bytes.Index(payload, []byte(needle)) - if idx < 0 { - return "" - } - start := idx + len(needle) - end := bytes.IndexByte(payload[start:], '"') - if end < 0 { - return "" - } - t := payload[start : start+end] - // Reject any value with control chars or escapes — we want a clean - // label, not arbitrary user-controlled metadata. Length-cap defensively. - if len(t) == 0 || len(t) > 64 { - return "" - } - for _, b := range t { - if b < 0x20 || b == '\\' { - return "" - } - } - return string(t) -} - -// wrapWithSeq injects a "seq" field into a JSON message without re-serializing. -func wrapWithSeq(msg []byte, seq uint64) []byte { - // Fast path: inject seq after the opening brace. - // e.g., {"type":"chat_message",...} → {"seq":123,"type":"chat_message",...} - // Guard: msg must be a non-empty JSON object (starts with '{' and has content). - if len(msg) < 2 || msg[0] != '{' { - return msg - } - prefix := fmt.Sprintf(`{"seq":%d,`, seq) - result := make([]byte, 0, len(prefix)+len(msg)-1) - result = append(result, prefix...) - result = append(result, msg[1:]...) // skip opening brace - return result -} - -// staleClientTimeout is the maximum duration a client can go without sending -// any message before being considered stale and disconnected. The client sends -// a ping every 30s, so 90s (3x) gives plenty of margin. -const staleClientTimeout = 90 * time.Second - // topicRateLimitPerSecond is the default maximum messages per second for any // single channel topic. Prevents a busy channel from saturating the broadcast // loop and starving other channels. const topicRateLimitPerSecond = 100 - -// sweepStaleClients iterates over all connected clients and kicks any that -// have not sent a message within staleClientTimeout. -func (h *Hub) sweepStaleClients() { - now := time.Now() - h.mu.RLock() - var stale []*Client - for _, c := range h.clients { - if now.Sub(c.getLastActivity()) > staleClientTimeout { - stale = append(stale, c) - } - } - h.mu.RUnlock() - - for _, c := range stale { - slog.Warn("hub: closing stale connection (no activity)", - "user_id", c.userID, "last_activity", c.getLastActivity()) - h.kickClient(c) - } -} - -// sweepRevokedSessions iterates all connected clients and kicks any whose -// session has been deleted, expired, or whose user has been banned. This -// provides time-based session enforcement for idle WebSocket connections -// that never trigger the message-count-based check (BUG-109). -func (h *Hub) sweepRevokedSessions() { - if h.db == nil { - return - } - // Hub run-loop sweeper — no request tie. - ctx := context.Background() - - h.mu.RLock() - snapshot := make([]*Client, 0, len(h.clients)) - for _, c := range h.clients { - if c.tokenHash != "" { - snapshot = append(snapshot, c) - } - } - h.mu.RUnlock() - - for _, c := range snapshot { - result, err := h.db.GetSessionWithBanStatus(ctx, c.tokenHash) - if err != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { - slog.Info("session sweep: revoked/expired session, disconnecting", - "user_id", c.userID) - h.kickClient(c) - continue - } - tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires} - if auth.IsEffectivelyBanned(tempUser) { - slog.Info("session sweep: banned user, disconnecting", - "user_id", c.userID) - c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned")) - h.kickClient(c) - } - } -} - -// sweepStaleVoiceStates queries all voice_states rows and removes any that -// don't match a connected client's voiceChID. This catches ghost users that -// slip through the primary cleanup paths (registerNow, readPump defer, -// LiveKit webhook). -func (h *Hub) sweepStaleVoiceStates() { - if h.db == nil { - return - } - // Hub run-loop sweeper — no request tie. - ctx := context.Background() - - // Revocation must evict a live session, not merely block the next join. - // Nothing else in ws re-validates voice permissions for a connection that - // stays open, so a user stripped of CONNECT_VOICE kept their SFU session - // until they disconnected. Checked once a minute, and only for the handful - // of clients actually in voice. - h.mu.RLock() - inVoice := make([]*Client, 0, len(h.clients)) - for _, c := range h.clients { - if c.getVoiceChID() != 0 { - inVoice = append(inVoice, c) - } - } - h.mu.RUnlock() - for _, c := range inVoice { - chID := c.getVoiceChID() - if chID == 0 || h.hasChannelPerm(ctx, c, chID, permissions.ConnectVoice) { - continue - } - slog.Warn("sweepStaleVoiceStates: evicting 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) - if err != nil { - slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err) - return - } - if len(allStates) == 0 { - return - } - - h.mu.RLock() - var stale []struct { - userID int64 - channelID int64 - joinedAt string - } - for _, vs := range allStates { - c, ok := h.clients[vs.UserID] - if !ok || c.getVoiceChID() != vs.ChannelID { - stale = append(stale, struct { - userID int64 - channelID int64 - joinedAt string - }{vs.UserID, vs.ChannelID, vs.JoinedAt}) - } - } - h.mu.RUnlock() - - for _, s := range stale { - // Channel-conditional delete: only removes the row if it still points - // at the channel we snapshotted. If the user rejoined or moved between - // the snapshot and now, the delete is a no-op and we skip the broadcast. - deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt) - if err != nil { - slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed", - "err", err, "user_id", s.userID, "channel_id", s.channelID) - continue - } - if !deleted { - continue - } - 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)) - if h.livekit != nil { - _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) - } - } -} - -// deliverBroadcast stamps bm.msg with a monotonic sequence number, stores it -// in the replay buffer, and sends it to the appropriate clients via pub/sub. -func (h *Hub) deliverBroadcast(bm broadcastMsg) { - h.seqMu.Lock() - defer h.seqMu.Unlock() - - seq := h.nextSeq() - msg := wrapWithSeq(bm.msg, seq) - - // Store in replay buffer for reconnection recovery. - h.replayBuf.Push(seq, bm.channelID, msg) - h.persistEvent(seq, bm.channelID, msg) - - // Fan out to plugins subscribed to this event type (Phase C Step 9). - // Dispatch is a no-op in the default build; the wazero build calls into - // the WASM module. Dispatch is called outside seqMu after we release it - // conceptually — but since seqMu is still held here, the call MUST NOT - // re-enter the hub. The default build is safe; the wazero build should - // dispatch asynchronously once the runtime is real. - if sink := h.pluginSink.Load(); sink != nil { - eventType := extractEventType(msg) - if eventType == "" { - eventType = "broadcast" - } - sink.Dispatch(context.Background(), eventType, msg) - } - - switch { - case bm.recipients != nil: - // Visibility-filtered fan-out: the audience was resolved by the caller. - for _, userID := range bm.recipients { - h.SendToUser(userID, msg) - } - case bm.channelID == 0: - // 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 - } - delivered := h.pubsub.Publish(topic, msg, 0) - slog.Debug("hub: channel broadcast", - "channel_id", bm.channelID, "delivered", delivered, "seq", seq) - } -} diff --git a/Server/ws/hub_broadcast.go b/Server/ws/hub_broadcast.go new file mode 100644 index 00000000..58e07abb --- /dev/null +++ b/Server/ws/hub_broadcast.go @@ -0,0 +1,507 @@ +package ws + +import ( + "context" + "log/slog" + "sync/atomic" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// broadcastMsg is an internal message queued for delivery. +type broadcastMsg struct { + channelID int64 // 0 = send to all connected clients + msg []byte + // recipients, when non-nil, replaces topic fan-out with direct delivery to + // exactly these user IDs. Used by voice_state/voice_leave: they are global + // in scope (every sidebar shows them) but must not disclose a channel the + // recipient's role may not READ, and the audience is resolved off the hub + // goroutine so deliverBroadcast stays free of permission queries. + recipients []int64 +} + +// BroadcastToChannel enqueues msg for delivery to all clients subscribed to +// channelID. When channelID is 0 the message is sent to every connected client. +// Non-blocking: if the broadcast channel is full the message is dropped with a warning. +func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) { + select { + case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}: + default: + h.broadcastDrops.Add(1) + slog.Warn("hub: broadcast channel full, dropping message", + "channel_id", channelID, "msg_len", len(msg)) + } +} + +// BroadcastToAll enqueues msg for delivery to every connected client. +// Non-blocking: if the broadcast channel is full the message is dropped with a warning. +func (h *Hub) BroadcastToAll(msg []byte) { + select { + case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}: + default: + h.broadcastDrops.Add(1) + slog.Warn("hub: broadcast channel full, dropping global message", + "msg_len", len(msg)) + } +} + +// broadcastVoiceEvent enqueues a voice_state / voice_leave message for the +// connected clients whose current role may READ channelID. +// +// These events used to go out via BroadcastToAll, which handed every +// authenticated client the membership and camera/mute state of voice channels +// that channel_overrides hides from their role — while the equivalent read path +// (buildReady) deliberately filters voice states to readable channels. Tagging +// the event with its real channel id also makes reconnect replay filter it, +// where a channelID of 0 was replayed unconditionally. +// +// 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") +} + +// broadcastChannelScoped enqueues msg for exactly the connected clients whose +// current role may READ channelID, tagged with that channel id so reconnect +// replay filters it too (EventsSinceFiltered replays a channelID of 0 +// unconditionally). kind only labels the drop warning. +func (h *Hub) broadcastChannelScoped(ctx context.Context, channelID int64, msg []byte, kind string) { + h.broadcastChannelScopedTo(channelID, msg, h.channelReadAudience(ctx, channelID), kind) +} + +// broadcastChannelScopedTo enqueues msg for a pre-resolved audience. Callers +// that fan out several messages for the same channel in one operation +// (CleanupVoiceForChannel) resolve the audience once via channelReadAudience +// and reuse it here, instead of re-running the role/override lookups per +// message. recipients is only read after enqueue, so sharing one slice across +// messages is safe. +func (h *Hub) broadcastChannelScopedTo(channelID int64, msg []byte, recipients []int64, kind string) { + bm := broadcastMsg{ + channelID: channelID, + msg: msg, + recipients: recipients, + } + select { + case h.broadcast <- bm: + default: + h.broadcastDrops.Add(1) + slog.Warn("hub: broadcast channel full, dropping "+kind, + "channel_id", channelID, "msg_len", len(msg)) + } +} + +// channelReadAudience returns the connected user IDs whose current role may READ +// channelID. Always non-nil, so an empty result means "deliver to nobody" +// rather than "no filter". Each user's verdict comes from the cached +// PermissionService when the hub has one (one in-memory lookup per connected +// user; a miss repopulates from the user's CURRENT role, so a mid-session +// reassignment is still honored). Caching is safe here because revocation is +// delivered synchronously at every mutation site: a role change calls +// InvalidateUser (admin/handlers_users.go) and a channel-override change calls +// InvalidateAll (admin/handlers_channel_perms.go) before the hub fan-out runs, +// with the 30s cache TTL as a backstop; the F6 gen-counter guard in the service +// prevents a populate racing an invalidation from caching stale data. Fails +// closed: a client whose role cannot be resolved is left out. Bare test hubs +// without a service fall back to live per-call lookups, memoised for the +// duration of the call. Mirrors RefreshChannelVisibility, which resolves +// visibility the same way. +func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 { + h.mu.RLock() + userIDs := make([]int64, 0, len(h.clients)) + for uid := range h.clients { + userIDs = append(userIDs, uid) + } + h.mu.RUnlock() + + audience := make([]int64, 0, len(userIDs)) + if h.perms != nil { + for _, uid := range userIDs { + if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) { + audience = append(audience, uid) + } + } + return audience + } + if h.db == nil || h.permChecker == nil { + return audience + } + visibleByRole := make(map[int64]bool) + for _, uid := range userIDs { + role, err := h.db.GetRoleForUser(ctx, uid) + if err != nil || role == nil { + continue + } + visible, ok := visibleByRole[role.ID] + if !ok { + visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, permissions.ReadMessages) + visibleByRole[role.ID] = visible + } + if visible { + audience = append(audience, uid) + } + } + return audience +} + +// BroadcastServerRestart sends a server_restart message to all connected clients. +// reason describes why the server is restarting (e.g., "update"). +// delaySeconds tells clients how long until the server actually shuts down. +func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) { + h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds)) +} + +// BroadcastChannelCreate sends a channel_create message to the connected +// clients whose current role may READ ch. It used to go out via BroadcastToAll, +// which handed every authenticated client the name, category and topic of a +// channel that channel_overrides hides from their role — metadata the ready +// payload (buildReady/VisibleChannelIDs) deliberately withholds. +// +// The admin HubBroadcaster interface carries no context, so — like +// RefreshChannelVisibility — the audience is resolved against Background: the +// fan-out must complete regardless of the triggering request. +func (h *Hub) BroadcastChannelCreate(ch *db.Channel) { + h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelCreate(ch), "channel_create") +} + +// BroadcastChannelUpdate sends a channel_update message to the connected +// clients whose current role may READ ch. Same disclosure as +// BroadcastChannelCreate; same filtered fan-out. +func (h *Hub) BroadcastChannelUpdate(ch *db.Channel) { + h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelUpdate(ch), "channel_update") +} + +// BroadcastChannelDelete sends a channel_delete message to all connected clients. +// +// Deliberately unfiltered: the payload is the bare channel id, with none of the +// metadata create/update carry, and by the time the admin handler calls this the +// channel row — and with it the ON DELETE CASCADE'd channel_overrides — is +// already gone, so a permission check here would answer from base role perms +// and could drop the delete for exactly the users who saw the channel via a +// positive override, stranding it in their sidebar. +func (h *Hub) BroadcastChannelDelete(channelID int64) { + h.BroadcastToAll(buildChannelDelete(channelID)) +} + +// RefreshChannelVisibility re-evaluates which connected clients may see ch +// after a channel_overrides change and sends targeted channel_create / +// channel_delete messages so sidebars converge without a reconnect. Clients +// that lose visibility are also unsubscribed from the channel topic and have +// their focused channel cleared so live messages stop flowing. +// +// The sends deliberately bypass the sequenced broadcast/replay path: a +// replayed channel_delete would be filtered by the allowed-channel set +// computed at replay time, which after an override change is exactly the +// inverse of the intended audience. Clients tolerate seq-less messages. +func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { + if ch == nil { + return + } + + h.mu.RLock() + clients := make([]*Client, 0, len(h.clients)) + for _, c := range h.clients { + clients = append(clients, c) + } + h.mu.RUnlock() + + // Called via the admin HubBroadcaster interface, which carries no context; + // the targeted re-sync must complete regardless of the triggering request. + ctx := context.Background() + + // Visibility is resolved per user. With a PermissionService it comes from + // the per-user cache — safe because the admin handlers invalidate + // (InvalidateAll on override change, InvalidateUser on role change) before + // calling into the hub, so the lookups below repopulate from post-change + // data; the 30s TTL is only a backstop and the F6 gen-counter guard keeps + // a racing populate from caching stale rows. Without a service (bare test + // hubs) each role is resolved live, once. + visibleByRole := make(map[int64]bool) + roleVisible := func(roleID int64) bool { + if v, ok := visibleByRole[roleID]; ok { + return v + } + visible := false + role, err := h.db.GetRoleByID(ctx, roleID) + if err == nil && role != nil { + // Single visibility predicate shared with buildReady / REST + // ListVisibleChannels; the checker fails closed on a lookup error + // and bypasses for admins, matching the other sites exactly. + visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages) + } + visibleByRole[roleID] = visible + return visible + } + + for _, c := range clients { + if c.user == nil { + continue + } + var visible bool + if h.perms != nil { + // The service resolves the user's CURRENT role internally (c.user + // is a connect-time snapshot), failing closed — an unresolvable + // role loses visibility rather than keeping a stale grant. + visible = h.perms.HasChannelPerm(ctx, c.user.ID, ch.ID, permissions.ReadMessages) + } else { + // c.user is a connect-time snapshot; an admin may have changed the + // user's role mid-session, so resolve the current role from the DB. + // Fail closed: on error send nothing rather than mis-target. + fresh, err := h.db.GetUserByID(ctx, c.user.ID) + if err != nil || fresh == nil { + slog.Warn("hub: RefreshChannelVisibility could not resolve user role", + "user_id", c.user.ID, "err", err) + continue + } + visible = roleVisible(fresh.RoleID) + } + if visible { + // Idempotent add on the client; also refreshes channel metadata. + c.sendMsg(buildChannelCreate(ch)) + continue + } + c.sendMsg(buildChannelDelete(ch.ID)) + h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID)) + c.mu.Lock() + if c.channelID == ch.ID { + c.channelID = 0 + } + c.mu.Unlock() + } + + // 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)) +} + +// BroadcastMemberBan sends a member_ban message to all connected clients +// and immediately disconnects the banned user's WebSocket connection (BUG-113). +func (h *Hub) BroadcastMemberBan(userID int64) { + h.BroadcastToAll(buildMemberBan(userID)) + h.DisconnectUser(userID) +} + +// DisconnectUser forcibly disconnects the client identified by userID. +// No-op if the user is not currently connected. +func (h *Hub) DisconnectUser(userID int64) { + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok { + return + } + slog.Info("hub: disconnecting user", "user_id", userID) + c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned")) + h.kickClient(c) +} + +// BroadcastUserUpdate sends a user_update message to all connected clients +// when a user changes their profile (username, avatar, identity key). +func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) { + h.BroadcastToAll(buildUserUpdate(userID, username, avatar, identityPublicKey)) +} + +// BroadcastMemberUpdate sends a member_update message to all connected clients +// and re-evaluates the reassigned user's live channel subscriptions. +func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { + h.BroadcastToAll(buildMemberUpdate(userID, roleName)) + h.revokeUnreadableChannels(userID) +} + +// revokeUnreadableChannels drops the channel-topic subscriptions the user's new +// role may no longer READ. READ_MESSAGES is checked once, at channel_focus, and +// then becomes a durable pub/sub subscription, so without this a demoted user +// keeps receiving every chat_message / chat_edited / reaction_update posted in +// the channels their old role could read for as long as the socket stays open. +// +// The per-client work mirrors RefreshChannelVisibility, the channel_overrides +// equivalent: targeted, unsequenced channel_delete + Unsubscribe (a replayed +// channel_delete would be filtered by the allowed set computed at replay time), +// then a visibilityChangeSeq bump so a client resuming across this change takes +// the full-ready path instead of replay. +// +// 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)) + + if h.db == nil { + return + } + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok || c.user == nil { + return + } + + // Called via the admin HubBroadcaster interface, which carries no context; + // the re-evaluation must complete regardless of the triggering request. + ctx := context.Background() + + // c.user is a connect-time snapshot and the role just changed, so resolve + // the current user — and through it the current role — from the DB. + var allowed map[int64]bool + user, err := h.db.GetUserByID(ctx, userID) + if err == nil && user != nil { + // Same predicate as the ready payload and reconnect replay filtering. + allowed, err = h.computeAllowedChannels(ctx, h.db, user) + } + if err != nil || user == nil { + // Visibility unresolved. Keeping the old subscriptions would leak, and + // revoking them all would hollow out a sidebar the user may still be + // entitled to, so close the socket instead: the client reconnects and + // rebuilds from a ready payload computed with the new role. kickClient + // rather than DisconnectUser — the latter sends a BANNED error, which + // makes the client clear its credentials instead of reconnecting. + slog.Warn("hub: role change visibility unresolved, closing socket", + "user_id", userID, "err", err) + h.kickClient(c) + return + } + + for _, topic := range h.pubsub.TopicsForClient(userID) { + chID := channelTopicID(topic) + if chID == 0 || allowed[chID] { + continue + } + // DM access is gated on dm_participants, which no role change can + // alter, while allowed sources DMs from dm_open_state — a DM the user + // has closed (or every DM, if the DM lookup inside + // computeAllowedChannels failed) is missing from allowed even though + // its subscription is still legitimate. Never revoke a DM topic here; + // on a lookup error close the socket rather than guess. + ch, chErr := h.db.GetChannel(ctx, chID) + if chErr != nil { + slog.Warn("hub: role change channel lookup failed, closing socket", + "user_id", userID, "channel_id", chID, "err", chErr) + h.kickClient(c) + return + } + if ch != nil && ch.Type == "dm" { + continue + } + c.sendMsg(buildChannelDelete(chID)) + h.pubsub.Unsubscribe(c, topic) + c.mu.Lock() + if c.channelID == chID { + c.channelID = 0 + } + c.mu.Unlock() + } +} + +// SendToUser delivers msg directly to the client identified by userID. +// Returns true if the client was found and the message was queued. +func (h *Hub) SendToUser(userID int64, msg []byte) bool { + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok { + return false + } + return c.trySendMsg(msg) +} + +// SendToUserHigh sends a high-priority message to a specific user. +func (h *Hub) SendToUserHigh(userID int64, msg []byte) bool { + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok { + return false + } + c.sendHighMsg(msg) + return true +} + +// BroadcastToAllLow enqueues a low-priority global broadcast. +// Low-priority messages are silently dropped if a client's buffer is full. +func (h *Hub) BroadcastToAllLow(msg []byte) { + // Low-priority global broadcasts bypass the sequenced broadcast channel + // and go directly through pub/sub — they don't need replay or seq numbering. + h.pubsub.PublishGlobalLow(msg) +} + +// sendSequencedToUsersHigh 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) { + h.seqMu.Lock() + defer h.seqMu.Unlock() + + seq := h.nextSeq() + wrapped := wrapWithSeq(msg, seq) + h.replayBuf.Push(seq, channelID, wrapped) + h.persistEvent(seq, channelID, wrapped) + + for _, userID := range userIDs { + h.SendToUserHigh(userID, wrapped) + } +} + +// deliverBroadcast stamps bm.msg with a monotonic sequence number, stores it +// in the replay buffer, and sends it to the appropriate clients via pub/sub. +func (h *Hub) deliverBroadcast(bm broadcastMsg) { + // The channel-broadcast debug log is emitted after seqMu is released + // (below) so a slow logging sink never extends the critical section that + // serializes every broadcast. + seq, delivered, channelSend := func() (seq uint64, delivered int, channelSend bool) { + h.seqMu.Lock() + defer h.seqMu.Unlock() + + seq = h.nextSeq() + msg := wrapWithSeq(bm.msg, seq) + + // Store in replay buffer for reconnection recovery. + h.replayBuf.Push(seq, bm.channelID, msg) + h.persistEvent(seq, bm.channelID, msg) + + // Fan out to plugins subscribed to this event type (Phase C Step 9). + // Dispatch is a no-op in the default build; the wazero build calls into + // the WASM module. Dispatch is called outside seqMu after we release it + // conceptually — but since seqMu is still held here, the call MUST NOT + // re-enter the hub. The default build is safe; the wazero build should + // dispatch asynchronously once the runtime is real. + if sink := h.pluginSink.Load(); sink != nil { + eventType := extractEventType(msg) + if eventType == "" { + eventType = "broadcast" + } + sink.Dispatch(context.Background(), eventType, msg) + } + + switch { + case bm.recipients != nil: + // Visibility-filtered fan-out: the audience was resolved by the caller. + for _, userID := range bm.recipients { + h.SendToUser(userID, msg) + } + case bm.channelID == 0: + // 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) + channelSend = true + } + return seq, delivered, channelSend + }() + + if channelSend { + slog.Debug("hub: channel broadcast", + "channel_id", bm.channelID, "delivered", delivered, "seq", seq) + } +} diff --git a/Server/ws/hub_channel_meta_test.go b/Server/ws/hub_channel_meta_test.go index f66381ad..aa57c001 100644 --- a/Server/ws/hub_channel_meta_test.go +++ b/Server/ws/hub_channel_meta_test.go @@ -58,9 +58,10 @@ func TestChannelMetadata_NotDeliveredToRolesDeniedRead(t *testing.T) { insiderSend := make(chan []byte, 64) outsiderSend := make(chan []byte, 64) + cOutsider := ws.NewTestClientWithUser(hub, outsider, 0, outsiderSend) hub.Register(ws.NewTestClientWithUser(hub, insider, 0, insiderSend)) - hub.Register(ws.NewTestClientWithUser(hub, outsider, 0, outsiderSend)) - time.Sleep(30 * time.Millisecond) + hub.Register(cOutsider) + waitRegistered(t, hub, cOutsider) // in-order events: both clients registered pub := &db.Channel{ID: pubID, Name: "chmeta-general", Type: "text", Category: "Text"} priv := &db.Channel{ @@ -71,7 +72,6 @@ func TestChannelMetadata_NotDeliveredToRolesDeniedRead(t *testing.T) { hub.BroadcastChannelUpdate(pub) hub.BroadcastChannelCreate(priv) hub.BroadcastChannelUpdate(priv) - time.Sleep(150 * time.Millisecond) // ── live delivery ───────────────────────────────────────────────────────── insiderLive := drainChanTimeout(insiderSend, 200*time.Millisecond) diff --git a/Server/ws/hub_events.go b/Server/ws/hub_events.go new file mode 100644 index 00000000..d871c977 --- /dev/null +++ b/Server/ws/hub_events.go @@ -0,0 +1,157 @@ +package ws + +import ( + "bytes" + "strconv" + "sync/atomic" + + "github.com/owncord/server/plugin" +) + +// nextSeq returns the next monotonic sequence number for broadcast messages. +func (h *Hub) nextSeq() uint64 { + return atomic.AddUint64(&h.seq, 1) +} + +// ReplayBuffer returns the hub's event ring buffer for reconnection replay. +func (h *Hub) ReplayBuffer() *EventRingBuffer { + return h.replayBuf +} + +// SeedSeq sets the hub's monotonic sequence counter to seed (atomic). Used +// at startup to align in-memory seqs with the persisted MAX(events.seq) so +// wrapped-payload seqs stay monotonic across restarts. Calling SeedSeq with +// a value less than the current seq is a no-op (we never go backwards). +func (h *Hub) SeedSeq(seed uint64) { + for { + cur := atomic.LoadUint64(&h.seq) + if seed <= cur { + return + } + if atomic.CompareAndSwapUint64(&h.seq, cur, seed) { + return + } + } +} + +// SetEventPersister attaches a persister so subsequent broadcasts are also +// written to the persistent EventStore. Pass nil to disable. Safe to call +// at any time, including after Run has started. +func (h *Hub) SetEventPersister(p *EventPersister) { + h.eventPersister.Store(p) +} + +// SetEventStore attaches a read-side EventStore used by the cold-tier +// reconnect replay path. Typically the same store backing SetEventPersister. +// Pass nil to disable. Safe to call at any time, including after Run has +// started. +func (h *Hub) SetEventStore(s EventStore) { + if s == nil { + h.eventStore.Store(nil) + return + } + h.eventStore.Store(&s) +} + +// SetPluginRegistry wires the plugin.Registry so the hub can dispatch +// slash commands (chat_command messages) to plugin-owned handlers. +// Pass nil to disable plugin command dispatch. Must be called before Run; +// late calls are ignored with an error log. +func (h *Hub) SetPluginRegistry(r *plugin.Registry) { + if h.rejectIfRunning("SetPluginRegistry") { + return + } + h.pluginRegistry = r +} + +// SetPluginEventSink wires the plugin.EventSink so the hub fans out each +// sequenced broadcast to subscribed plugins. Pass nil to disable. Safe to +// call at any time, including after Run has started. +func (h *Hub) SetPluginEventSink(s *plugin.EventSink) { + h.pluginSink.Store(s) +} + +// ReconnectTierStats returns the per-tier resume hit counters in the order +// (buffer, db, full). Phase B Step 7 metrics surface; OpenTelemetry meters +// (Step 8) read from the same atomics. +func (h *Hub) ReconnectTierStats() (buffer, db, full uint64) { + return h.reconnectTierBuf.Load(), h.reconnectTierDB.Load(), h.reconnectTierFull.Load() +} + +// mustFullResync reports whether a client resuming from lastSeq predates the +// most recent channel-visibility change and therefore cannot converge via +// replay. +func (h *Hub) mustFullResync(lastSeq uint64) bool { + w := h.visibilityChangeSeq.Load() + return w > 0 && lastSeq <= w +} + +// persistEvent enqueues a broadcast event for cold-storage persistence. Safe +// to call with a nil persister; never blocks the broadcast hot path. seq is +// the same hub-assigned monotonic counter embedded in payload, so the row +// written to the EventStore has a row-seq that matches the wrapped-payload +// seq the client tracks. +func (h *Hub) persistEvent(seq uint64, channelID int64, payload []byte) { + p := h.eventPersister.Load() + if p == nil { + return + } + eventType := extractEventType(payload) + if eventType == "" { + eventType = "broadcast" + if channelID != 0 { + eventType = "channel_broadcast" + } + } + p.Enqueue(int64(seq), eventType, channelID, payload) //nolint:gosec // seq is a monotonically increasing counter, never reaches MaxInt64 +} + +// extractEventType scans a wrapped JSON envelope for the value of the "type" +// field and returns it. Returns "" on any parse failure so the caller can +// substitute a generic label. The scan is intentionally not a full JSON +// decode — it only looks for the literal `"type":""` token, which +// matches every wire-format envelope produced by this server. This avoids the +// allocation cost of `encoding/json` on the broadcast hot path. +func extractEventType(payload []byte) string { + const needle = `"type":"` + idx := bytes.Index(payload, []byte(needle)) + if idx < 0 { + return "" + } + start := idx + len(needle) + end := bytes.IndexByte(payload[start:], '"') + if end < 0 { + return "" + } + t := payload[start : start+end] + // Reject any value with control chars or escapes — we want a clean + // label, not arbitrary user-controlled metadata. Length-cap defensively. + if len(t) == 0 || len(t) > 64 { + return "" + } + for _, b := range t { + if b < 0x20 || b == '\\' { + return "" + } + } + return string(t) +} + +// wrapWithSeq injects a "seq" field into a JSON message without re-serializing. +func wrapWithSeq(msg []byte, seq uint64) []byte { + // Fast path: inject seq after the opening brace. + // e.g., {"type":"chat_message",...} → {"seq":123,"type":"chat_message",...} + // Guard: msg must be a non-empty JSON object (starts with '{' and has content). + if len(msg) < 2 || msg[0] != '{' { + return msg + } + // `{"seq":` + up-to-20-digit uint64 + `,` = at most 28 extra bytes; the + // single make below is the only allocation on this hot path (the previous + // fmt.Sprintf built an intermediate string first). + result := make([]byte, 0, len(msg)+28) + result = append(result, `{"seq":`...) + result = strconv.AppendUint(result, seq, 10) + result = append(result, ',') + result = append(result, msg[1:]...) // skip opening brace + return result +} diff --git a/Server/ws/hub_livekit.go b/Server/ws/hub_livekit.go new file mode 100644 index 00000000..2f719136 --- /dev/null +++ b/Server/ws/hub_livekit.go @@ -0,0 +1,53 @@ +package ws + +import ( + "context" + "fmt" +) + +// SetLiveKit sets the LiveKit client on the hub. Must be called before Run; +// late calls are ignored with an error log. +func (h *Hub) SetLiveKit(lk *LiveKitClient) { + if h.rejectIfRunning("SetLiveKit") { + return + } + h.livekit = lk +} + +// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit +// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed +// as a dep at registration time (before SetLiveKit is called). +func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) { + if h.livekit == nil { + return "", fmt.Errorf("voice not configured") + } + return h.livekit.GenerateToken(userID, username, channelID, voiceJoinToken, canPublish, canSubscribe, canVideo, canScreenShare) +} + +// URL delegates to the LiveKit client. Returns empty string if not configured. +func (h *Hub) URL() string { + if h.livekit == nil { + return "" + } + return h.livekit.URL() +} + +// LiveKitHealthCheck probes the LiveKit server for connectivity. +// It tries the SDK client first (ListRooms), and falls back to an HTTP probe +// if a managed process is configured. Returns false with a reason if LiveKit +// is not configured or unreachable. +func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) { + if h.livekit == nil { + return false, fmt.Errorf("not configured") + } + return h.livekit.HealthCheck(ctx) +} + +// SetLiveKitProcess sets the LiveKit process manager on the hub. Must be +// called before Run; late calls are ignored with an error log. +func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) { + if h.rejectIfRunning("SetLiveKitProcess") { + return + } + h.lkProcess = p +} diff --git a/Server/ws/hub_sweep.go b/Server/ws/hub_sweep.go new file mode 100644 index 00000000..a5f4bcac --- /dev/null +++ b/Server/ws/hub_sweep.go @@ -0,0 +1,248 @@ +package ws + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// staleClientTimeout is the maximum duration a client can go without sending +// any message before being considered stale and disconnected. The client sends +// a ping every 30s, so 90s (3x) gives plenty of margin. +const staleClientTimeout = 90 * time.Second + +// 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. +func (h *Hub) kickClient(c *Client) { + h.mu.Lock() + if current, ok := h.clients[c.userID]; ok && current == c { + delete(h.clients, c.userID) + } + h.mu.Unlock() + h.pubsub.UnsubscribeAll(c) + c.closeSend() +} + +// startSweep runs sweep on its own goroutine so the hub dispatch loop never +// blocks on the DB-heavy periodic sweeps (they already lock correctly for +// concurrent execution with the hub). inFlight guarantees a sweep never runs +// concurrently with itself: a tick arriving while the previous run is still +// going is dropped, and the next tick retries. +func (h *Hub) startSweep(inFlight *atomic.Bool, sweep func()) { + if !inFlight.CompareAndSwap(false, true) { + return + } + go func() { + defer inFlight.Store(false) + sweep() + }() +} + +// sweepStaleClients iterates over all connected clients and kicks any that +// have not sent a message within staleClientTimeout. +func (h *Hub) sweepStaleClients() { + now := time.Now() + h.mu.RLock() + var stale []*Client + for _, c := range h.clients { + if now.Sub(c.getLastActivity()) > staleClientTimeout { + stale = append(stale, c) + } + } + h.mu.RUnlock() + + for _, c := range stale { + slog.Warn("hub: closing stale connection (no activity)", + "user_id", c.userID, "last_activity", c.getLastActivity()) + h.kickClient(c) + } +} + +// sweepRevokedSessions iterates all connected clients and kicks any whose +// session has been deleted, expired, or whose user has been banned. This +// provides time-based session enforcement for idle WebSocket connections +// that never trigger the message-count-based check (BUG-109). +func (h *Hub) sweepRevokedSessions() { + if h.db == nil { + return + } + // Hub run-loop sweeper — no request tie. + ctx := context.Background() + + h.mu.RLock() + snapshot := make([]*Client, 0, len(h.clients)) + for _, c := range h.clients { + if c.tokenHash != "" { + snapshot = append(snapshot, c) + } + } + h.mu.RUnlock() + + if len(snapshot) == 0 { + return + } + + // One batched lookup for every connected client instead of a query per + // client per sweep. + hashes := make([]string, len(snapshot)) + for i, c := range snapshot { + hashes[i] = c.tokenHash + } + sessions, err := h.db.GetSessionsWithBanStatusBatch(ctx, hashes) + if err != nil { + // A failed batch lookup says nothing about any individual session — + // kicking everyone on a transient DB error would be a mass disconnect. + // Skip this sweep; the next tick retries. + slog.Warn("session sweep: batch session lookup failed", "err", err) + return + } + + for _, c := range snapshot { + result := sessions[c.tokenHash] + if result == nil || auth.IsSessionExpired(result.ExpiresAt) { + slog.Info("session sweep: revoked/expired session, disconnecting", + "user_id", c.userID) + h.kickClient(c) + continue + } + tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires} + if auth.IsEffectivelyBanned(tempUser) { + slog.Info("session sweep: banned user, disconnecting", + "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned")) + h.kickClient(c) + } + } +} + +// sweepStaleVoiceStates queries all voice_states rows and removes any that +// don't match a connected client's voiceChID. This catches ghost users that +// slip through the primary cleanup paths (registerNow, readPump defer, +// LiveKit webhook). +func (h *Hub) sweepStaleVoiceStates() { + if h.db == nil { + return + } + // Hub run-loop sweeper — no request tie. + ctx := context.Background() + + // Revocation must evict a live session, not merely block the next join. + // Nothing else in ws re-validates voice permissions for a connection that + // stays open, so a user stripped of CONNECT_VOICE kept their SFU session + // until they disconnected. Checked once a minute, and only for the handful + // of clients actually in voice. + h.mu.RLock() + inVoice := make([]*Client, 0, len(h.clients)) + for _, c := range h.clients { + if c.getVoiceChID() != 0 { + inVoice = append(inVoice, c) + } + } + h.mu.RUnlock() + for _, c := range inVoice { + chID := c.getVoiceChID() + if chID == 0 || h.hasChannelPerm(ctx, c, chID, permissions.ConnectVoice) { + continue + } + slog.Warn("sweepStaleVoiceStates: evicting 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) + if err != nil { + slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err) + return + } + if len(allStates) == 0 { + return + } + + h.mu.RLock() + var stale []struct { + userID int64 + channelID int64 + joinedAt string + } + for _, vs := range allStates { + c, ok := h.clients[vs.UserID] + if !ok || c.getVoiceChID() != vs.ChannelID { + stale = append(stale, struct { + userID int64 + channelID int64 + joinedAt string + }{vs.UserID, vs.ChannelID, vs.JoinedAt}) + } + } + h.mu.RUnlock() + + for _, s := range stale { + // Channel-conditional delete: only removes the row if it still points + // at the channel we snapshotted. If the user rejoined or moved between + // the snapshot and now, the delete is a no-op and we skip the broadcast. + deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt) + if err != nil { + slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed", + "err", err, "user_id", s.userID, "channel_id", s.channelID) + continue + } + if !deleted { + continue + } + 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)) + if h.livekit != nil { + _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) + } + } +} + +// CleanupVoiceForChannel removes all voice participants from the given channel. +// Called when a channel is deleted. +func (h *Hub) CleanupVoiceForChannel(channelID int64) { + // Cleanup must complete even if the triggering request goes away. + ctx := context.Background() + // Get all users in the channel's voice state from DB. + states, err := h.db.GetChannelVoiceStates(ctx, channelID) + if err != nil { + slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID) + return + } + if len(states) == 0 { + return + } + + // Clean up DB state and LiveKit for each participant. + 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) + } + + // Clear client voice state. + h.mu.RLock() + if client, ok := h.clients[vs.UserID]; ok { + client.clearVoiceChID() + } + h.mu.RUnlock() + + // Remove from LiveKit (best-effort). + if h.livekit != nil { + _ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt) + } + } + + // Broadcast voice_leave for each participant. All leaves target the same + // channel, so resolve the READ audience once and reuse it per message. + audience := h.channelReadAudience(ctx, channelID) + 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 new file mode 100644 index 00000000..2b20b49c --- /dev/null +++ b/Server/ws/hub_sweep_test.go @@ -0,0 +1,64 @@ +package ws + +import ( + "sync/atomic" + "testing" + "time" +) + +// TestStartSweep_NeverRunsConcurrentlyWithItself locks the in-flight guard +// shut: while one sweep is still running, further startSweep calls for the +// same guard must be dropped, and once it finishes the next call runs again. +func TestStartSweep_NeverRunsConcurrentlyWithItself(t *testing.T) { + h := &Hub{} + + var inFlight atomic.Bool + var active, maxActive, runs atomic.Int64 + release := make(chan struct{}) + + sweep := func() { + cur := active.Add(1) + if cur > maxActive.Load() { + maxActive.Store(cur) + } + runs.Add(1) + <-release + active.Add(-1) + } + + // First call claims the guard; the sweep blocks on release. + h.startSweep(&inFlight, sweep) + // Wait until the goroutine is actually inside the sweep. + for active.Load() == 0 { + time.Sleep(time.Millisecond) + } + + // Ticks arriving mid-sweep must be dropped, not stacked. + for range 5 { + h.startSweep(&inFlight, sweep) + } + if got := runs.Load(); got != 1 { + t.Fatalf("runs = %d while first sweep still in flight, want 1", got) + } + + close(release) + for inFlight.Load() { + time.Sleep(time.Millisecond) + } + + // Guard released — the next tick runs a fresh sweep. + done := make(chan struct{}) + h.startSweep(&inFlight, func() { close(done) }) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("sweep did not run after the previous one finished") + } + + if got := maxActive.Load(); got != 1 { + t.Fatalf("max concurrent sweeps = %d, want 1", got) + } + if got := runs.Load(); got != 1 { + t.Fatalf("blocking sweep ran %d times, want 1", got) + } +} diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index cb5667bb..d993e880 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -93,8 +93,8 @@ func TestHub_RunStops(t *testing.T) { hub.Run() close(done) }() - // Give the goroutine a moment to start, then stop the hub. - time.Sleep(10 * time.Millisecond) + // Wait for the Run loop to start, then stop the hub. + waitFor(t, waitTimeout, hub.RunningForTest, "hub Run loop to start") hub.Stop() select { case <-done: @@ -115,7 +115,7 @@ func TestHub_RegisterIncrementsCount(t *testing.T) { send := make(chan []byte, 4) hub.Register(ws.NewTestClient(hub, userID, send)) - time.Sleep(20 * time.Millisecond) + waitClientCount(t, hub, 1) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d, want 1", hub.ClientCount()) } @@ -130,10 +130,10 @@ func TestHub_UnregisterDecrementsCount(t *testing.T) { send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.Unregister(c) - time.Sleep(20 * time.Millisecond) + waitClientCount(t, hub, 0) if hub.ClientCount() != 0 { t.Errorf("ClientCount = %d, want 0", hub.ClientCount()) } @@ -148,9 +148,12 @@ func TestHub_RegisterSameUserTwice(t *testing.T) { userID := seedTestUser(t, database, "carol") send1 := make(chan []byte, 4) send2 := make(chan []byte, 4) + c2 := ws.NewTestClient(hub, userID, send2) hub.Register(ws.NewTestClient(hub, userID, send1)) - hub.Register(ws.NewTestClient(hub, userID, send2)) - time.Sleep(30 * time.Millisecond) + hub.Register(c2) + // Client events are processed in order: once c2 is visible, the first + // registration has been replaced. + waitRegistered(t, hub, c2) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after double register, want 1", hub.ClientCount()) @@ -168,13 +171,13 @@ func TestHub_BroadcastToAll_DeliversToAllClients(t *testing.T) { u2 := seedTestUser(t, database, "eve") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) + c2 := ws.NewTestClient(hub, u2, s2) hub.Register(ws.NewTestClient(hub, u1, s1)) - hub.Register(ws.NewTestClient(hub, u2, s2)) - time.Sleep(20 * time.Millisecond) + hub.Register(c2) + waitRegistered(t, hub, c2) // in-order events: both clients registered msg := []byte(`{"type":"presence","payload":{}}`) hub.BroadcastToAll(msg) - time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "client 1") assertReceived(t, s2, msg, "client 2") @@ -207,11 +210,10 @@ func TestHub_BroadcastToChannel_OnlySendsToChannelMembers(t *testing.T) { hub.Register(c1) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) msg := []byte(`{"type":"chat_message","payload":{}}`) hub.BroadcastToChannel(chID, msg) - time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "channel member") assertNotReceived(t, s2, "non-member") @@ -224,12 +226,12 @@ func TestHub_BroadcastToChannel_ZeroChannelSendsToAll(t *testing.T) { u1 := seedTestUser(t, database, "henry") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) msg := []byte(`{"type":"presence","payload":{}}`) hub.BroadcastToChannel(0, msg) - time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "client") } @@ -252,11 +254,10 @@ func TestHub_BroadcastToChannel_SkipsUnfocusedClient(t *testing.T) { hub.Register(c1) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) msg := []byte(`{"type":"chat_message","payload":{"content":"secret"}}`) hub.BroadcastToChannel(chID, msg) - time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "focused client") assertNotReceived(t, s2, "unfocused client must NOT receive channel broadcast") @@ -279,11 +280,10 @@ func TestHub_BroadcastToChannel_NotDeliveredOnVoiceMembershipAlone(t *testing.T) ws.SetClientVoiceChID(c1, chID) // but in voice on this channel hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) msg := []byte(`{"type":"chat_message","payload":{"content":"hello"}}`) hub.BroadcastToChannel(chID, msg) - time.Sleep(20 * time.Millisecond) assertNotReceived(t, s1, "voice membership alone must NOT deliver the channel message stream") } @@ -328,7 +328,6 @@ func TestHub_RegisterNow_VoiceChannelSubscriptionFollowsReadPermission(t *testin msg := []byte(`{"type":"chat_message","payload":{"content":"hello"}}`) hub.BroadcastToChannel(chID, msg) - time.Sleep(20 * time.Millisecond) if tc.want { assertReceived(t, s1, msg, "voice client with READ_MESSAGES") @@ -349,11 +348,10 @@ func TestHub_BroadcastToAll_StillDeliversToUnfocusedClient(t *testing.T) { c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0 (unfocused) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) msg := []byte(`{"type":"presence","payload":{"status":"online"}}`) hub.BroadcastToAll(msg) - time.Sleep(20 * time.Millisecond) assertReceived(t, s1, msg, "unfocused client must still receive global broadcasts") } @@ -371,13 +369,12 @@ func TestHub_BroadcastToChannel_UnfocusedDoesNotReceiveAnyChannel(t *testing.T) c1 := ws.NewTestClient(hub, u1, s1) // unfocused hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) msg1 := []byte(`{"type":"chat_message","payload":{"channel":"1"}}`) msg2 := []byte(`{"type":"chat_message","payload":{"channel":"2"}}`) hub.BroadcastToChannel(ch1, msg1) hub.BroadcastToChannel(ch2, msg2) - time.Sleep(20 * time.Millisecond) assertNotReceived(t, s1, "unfocused client must NOT receive ch1 broadcast") } @@ -391,15 +388,15 @@ func TestHub_SendToUser_ExistingClient(t *testing.T) { userID := seedTestUser(t, database, "ivan") send := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, userID, send)) - time.Sleep(20 * time.Millisecond) + c := ws.NewTestClient(hub, userID, send) + hub.Register(c) + waitRegistered(t, hub, c) msg := []byte(`{"type":"chat_send_ok","payload":{}}`) ok := hub.SendToUser(userID, msg) if !ok { t.Error("SendToUser returned false for existing client") } - time.Sleep(20 * time.Millisecond) assertReceived(t, send, msg, "target user") } @@ -425,11 +422,10 @@ func TestHub_HandleMessage_UnknownType_SendsError(t *testing.T) { send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) raw := []byte(`{"type":"totally_unknown","payload":{}}`) hub.HandleMessageForTest(c, raw) - time.Sleep(20 * time.Millisecond) select { case got := <-send: @@ -454,10 +450,9 @@ func TestHub_HandleMessage_InvalidJSON(t *testing.T) { send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, []byte(`NOT JSON`)) - time.Sleep(20 * time.Millisecond) select { case got := <-send: @@ -485,7 +480,7 @@ func TestHub_ChatSend_RateLimit(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) payload := map[string]any{ "channel_id": chID, @@ -500,9 +495,9 @@ func TestHub_ChatSend_RateLimit(t *testing.T) { for range 12 { hub.HandleMessageForTest(c, raw) } - time.Sleep(100 * time.Millisecond) - // Drain all messages, count errors. + // Drain all messages, count errors — error replies are sent synchronously + // by handleMessage, so they are already buffered on the send channel. errCount := 0 drainLoop: for { @@ -540,17 +535,14 @@ func TestHub_ConcurrentRegisterUnregister(t *testing.T) { send := make(chan []byte, 4) c := ws.NewTestClient(hub, userID, send) hub.Register(c) - time.Sleep(5 * time.Millisecond) + waitRegistered(t, hub, c) hub.Unregister(c) }(i) } wg.Wait() // The hub loop drains register/unregister asynchronously; poll instead of // a fixed sleep, which flakes under -race on slow runners. - deadline := time.Now().Add(5 * time.Second) - for hub.ClientCount() != 0 && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } + waitFor(t, 5*time.Second, func() bool { return hub.ClientCount() == 0 }, "churned clients to unregister") if hub.ClientCount() != 0 { t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount()) } @@ -565,7 +557,7 @@ func TestHub_GetClient(t *testing.T) { hub.Register(client) go hub.Run() defer hub.Stop() - time.Sleep(10 * time.Millisecond) + waitRegistered(t, hub, client) got := hub.GetClient(42) if got == nil { @@ -640,7 +632,7 @@ func TestHub_GracefulStop_StopsHub(t *testing.T) { hub.Run() close(done) }() - time.Sleep(10 * time.Millisecond) + waitFor(t, waitTimeout, hub.RunningForTest, "hub Run loop to start") hub.GracefulStop() @@ -717,13 +709,13 @@ func TestHub_SweepStaleClients_RemovesInactiveClients(t *testing.T) { hub.Register(c1) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) ws.SetClientLastActivityForTest(c1, time.Now().Add(-2*time.Minute)) ws.SetClientLastActivityForTest(c2, time.Now()) + // sweepStaleClients kicks synchronously (kickClient) — no wait needed. hub.SweepStaleClientsForTest() - time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after sweep, want 1", hub.ClientCount()) @@ -750,11 +742,11 @@ func TestHub_SweepStaleClients_AllFresh(t *testing.T) { s1 := make(chan []byte, 4) c1 := ws.NewTestClient(hub, u1, s1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) ws.SetClientLastActivityForTest(c1, time.Now()) + // sweepStaleClients kicks synchronously — its effects are visible on return. hub.SweepStaleClientsForTest() - time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after sweep of fresh clients, want 1", hub.ClientCount()) @@ -803,16 +795,15 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { hub.Register(c1) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) // Delete alice's session (simulating logout from another device). if err := database.DeleteSession(context.Background(), hash1); err != nil { t.Fatalf("DeleteSession: %v", err) } - // Run the session sweep. + // Run the session sweep — it kicks synchronously (kickClient). hub.SweepRevokedSessionsForTest() - time.Sleep(20 * time.Millisecond) // Alice should be kicked, Bob should remain. if hub.GetClient(uid1) != nil { @@ -826,6 +817,41 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { } } +// TestHub_SweepRevokedSessions_KicksBannedClient verifies the sweep's batched +// session lookup carries ban status through: a client whose user was banned +// after connecting is disconnected even though its session row still exists. +func TestHub_SweepRevokedSessions_KicksBannedClient(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + uid, err := database.CreateUser(context.Background(), "soon-banned", "hash", 3) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + u, _ := database.GetUserByID(context.Background(), uid) + + hash := auth.HashToken("ban-sweep-token") + if _, err := database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + s := make(chan []byte, 4) + c := ws.NewTestClientWithTokenHash(hub, u, hash, 0, s) + hub.Register(c) + waitRegistered(t, hub, c) + + if err := database.BanUser(context.Background(), uid, "rule violation", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + hub.SweepRevokedSessionsForTest() + + if hub.GetClient(uid) != nil { + t.Error("banned client should have been kicked by the session sweep") + } +} + // TestHub_SweepRevokedSessions_NoDBNoPanic verifies the sweep is a no-op // when the hub has no database (nil-safe). func TestHub_SweepRevokedSessions_NoDBNoPanic(t *testing.T) { @@ -844,10 +870,9 @@ func TestHub_SweepRevokedSessions_EmptyTokenHashSkipped(t *testing.T) { s := make(chan []byte, 4) c := ws.NewTestClient(hub, uid, s) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.SweepRevokedSessionsForTest() - time.Sleep(20 * time.Millisecond) if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d, want 1 (client without token hash should survive)", hub.ClientCount()) @@ -905,8 +930,8 @@ func TestHub_VoiceSessionCount(t *testing.T) { ws.SetClientVoiceChID(c, vch) } hub.Register(c) + waitRegistered(t, hub, c) } - time.Sleep(30 * time.Millisecond) got := hub.VoiceSessionCount() if got != tc.wantCount { @@ -983,7 +1008,7 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend) hub.Register(ownerClient) hub.Register(memberClient) - time.Sleep(30 * time.Millisecond) + waitRegistered(t, hub, memberClient) // Hide the channel from the Member role (deny ReadMessages). if _, err := database.ExecContext(context.Background(), @@ -1068,7 +1093,7 @@ func TestBroadcastMemberUpdate_RevokesUnreadableSubscriptions(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chID, send) hub.Register(c) - time.Sleep(30 * time.Millisecond) + waitRegistered(t, hub, c) // The user is a participant of this DM but has closed it (no dm_open_state // row), so the topic is held while being absent from the allowed set. hub.PubSubForTest().Subscribe(c, ws.ChannelTopic(dmID)) @@ -1106,6 +1131,8 @@ func TestBroadcastMemberUpdate_RevokesUnreadableSubscriptions(t *testing.T) { } // The impact itself: channel traffic no longer reaches the demoted socket. + // Absence assertion: bounded window for a wrongly-delivered message to + // arrive before checking the buffer. hub.BroadcastToChannel(chID, []byte(`{"type":"chat_message"}`)) time.Sleep(30 * time.Millisecond) assertNoMsgType(t, send, "chat_message") @@ -1132,8 +1159,9 @@ func TestBroadcastMemberUpdate_ClosesSocketWhenVisibilityUnresolved(t *testing.T t.Fatalf("GetUserByID: %v", err) } send := make(chan []byte, 16) - hub.Register(ws.NewTestClientWithUser(hub, user, chID, send)) - time.Sleep(30 * time.Millisecond) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) // Break the override lookup computeAllowedChannels depends on. if _, err := database.ExecContext(ctx, `DROP TABLE channel_overrides`); err != nil { diff --git a/Server/ws/livekit_download.go b/Server/ws/livekit_download.go new file mode 100644 index 00000000..e3ff1605 --- /dev/null +++ b/Server/ws/livekit_download.go @@ -0,0 +1,338 @@ +package ws + +// Auto-download of the companion livekit-server binary. +// +// When voice.auto_download_livekit is enabled and no voice.livekit_binary is +// configured, the server fetches a pinned livekit-server release from the +// official LiveKit GitHub releases, verifies it against the release's +// checksums.txt, and stores it under /livekit/. The version is +// pinned (overridable via voice.livekit_version) so a boot never silently +// picks up a new upstream release. + +import ( + "archive/tar" + "archive/zip" + "bufio" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// DefaultLiveKitVersion is the livekit-server release the server downloads +// when voice.livekit_version is not set. Bump deliberately with releases. +const DefaultLiveKitVersion = "1.13.5" + +// livekitDownloadBase is the release download URL prefix. Package variable so +// tests can point it at a local httptest server. +var livekitDownloadBase = "https://github.com/livekit/livekit/releases/download" + +const ( + // maxLiveKitArchiveSize caps the archive download (the real archive is + // ~40 MB compressed). + maxLiveKitArchiveSize = 200 * 1024 * 1024 + // maxChecksumsSize caps the checksums.txt download. + maxChecksumsSize = 1 * 1024 * 1024 +) + +// livekitAssetName maps GOOS/GOARCH to the release asset file name, following +// LiveKit's goreleaser config (linux/windows on amd64/arm64/armv7; archives +// are tar.gz except zip on windows; checksum file is "checksums.txt"). +func livekitAssetName(version, goos, goarch string) (string, error) { + var arch string + switch goarch { + case "amd64", "arm64": + arch = goarch + case "arm": + arch = "armv7" + default: + return "", fmt.Errorf("livekit auto-download does not support architecture %s — set voice.livekit_binary to a livekit-server binary you provide", goarch) + } + switch goos { + case "linux": + return fmt.Sprintf("livekit_%s_linux_%s.tar.gz", version, arch), nil + case "windows": + return fmt.Sprintf("livekit_%s_windows_%s.zip", version, arch), nil + default: + return "", fmt.Errorf("livekit auto-download does not support OS %s — set voice.livekit_binary to a livekit-server binary you provide", goos) + } +} + +// livekitBinaryFilename is the versioned name the extracted binary is stored +// under. Embedding the version means a bumped pin downloads fresh instead of +// reusing a stale cached binary. +func livekitBinaryFilename(version string) string { + name := "livekit-server-" + version + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +// EnsureLiveKitBinary returns the path to a verified livekit-server binary +// for the given release version (empty = DefaultLiveKitVersion), downloading +// and extracting it into /livekit/ if it is not already cached. +func EnsureLiveKitBinary(ctx context.Context, dataDir, version string) (string, error) { + version = strings.TrimPrefix(version, "v") + if version == "" { + version = DefaultLiveKitVersion + } + asset, err := livekitAssetName(version, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err + } + + dir := filepath.Join(dataDir, "livekit") + dest := filepath.Join(dir, livekitBinaryFilename(version)) + if info, statErr := os.Stat(dest); statErr == nil && info.Mode().IsRegular() && info.Size() > 0 { + return dest, nil + } + + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("creating livekit dir: %w", err) + } + + base := livekitDownloadBase + "/v" + version + slog.Info("livekit: downloading livekit-server (one-time)", "version", version, "asset", asset) + + sums, err := fetchLimited(ctx, base+"/checksums.txt", maxChecksumsSize) + if err != nil { + return "", fmt.Errorf("fetching livekit checksums: %w", err) + } + expectedHash, err := parseChecksumLine(sums, asset) + if err != nil { + return "", err + } + + // Download the archive next to the destination so the final rename stays + // on one filesystem. O_EXCL via downloadTo refuses pre-planted files. + archivePath := dest + ".download" + _ = os.Remove(archivePath) + defer os.Remove(archivePath) //nolint:errcheck // best-effort cleanup + + if err := downloadTo(ctx, base+"/"+asset, archivePath, maxLiveKitArchiveSize); err != nil { + return "", fmt.Errorf("downloading %s: %w", asset, err) + } + + // Verify and extract through one open handle so the bytes verified are + // the bytes extracted even if the path is swapped in between (TOCTOU). + f, err := os.Open(archivePath) //nolint:gosec // G304: path constructed from trusted config + if err != nil { + return "", fmt.Errorf("opening archive: %w", err) + } + defer f.Close() //nolint:errcheck + + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return "", fmt.Errorf("hashing archive: %w", err) + } + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expectedHash) { + return "", fmt.Errorf("livekit archive checksum mismatch for %s: expected %s, got %s", asset, expectedHash, actual) + } + + tmpBin := dest + ".tmp" + _ = os.Remove(tmpBin) + if strings.HasSuffix(asset, ".zip") { + err = extractLiveKitFromZip(f, size, tmpBin) + } else { + if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil { + return "", fmt.Errorf("rewinding archive: %w", seekErr) + } + err = extractLiveKitFromTarGz(f, tmpBin) + } + if err != nil { + _ = os.Remove(tmpBin) + return "", fmt.Errorf("extracting %s: %w", asset, err) + } + if err := os.Chmod(tmpBin, 0o755); err != nil { //nolint:gosec // G302: must be executable + _ = os.Remove(tmpBin) + return "", fmt.Errorf("chmod binary: %w", err) + } + if err := os.Rename(tmpBin, dest); err != nil { + _ = os.Remove(tmpBin) + return "", fmt.Errorf("staging binary: %w", err) + } + + cleanupOldLiveKitBinaries(dir, filepath.Base(dest)) + slog.Info("livekit: download complete", "path", dest) + return dest, nil +} + +// livekitBinaryEntry reports whether an archive entry name is the +// livekit-server binary (archives contain it at the top level plus LICENSE). +func livekitBinaryEntry(name string) bool { + base := filepath.Base(filepath.ToSlash(name)) + return base == "livekit-server" || base == "livekit-server.exe" +} + +// extractLiveKitFromTarGz extracts the livekit-server entry to destPath +// (created O_EXCL so a pre-planted file fails the extraction). +func extractLiveKitFromTarGz(r io.Reader, destPath string) error { + gr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gr.Close() //nolint:errcheck + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return fmt.Errorf("archive contains no livekit-server binary") + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + if hdr.Typeflag != tar.TypeReg || strings.Contains(hdr.Name, "..") || !livekitBinaryEntry(hdr.Name) { + continue + } + return writeExact(destPath, io.LimitReader(tr, hdr.Size), hdr.Size) + } +} + +// extractLiveKitFromZip extracts the livekit-server entry to destPath +// (created O_EXCL). r must be positioned over the verified archive bytes. +func extractLiveKitFromZip(r io.ReaderAt, size int64, destPath string) error { + zr, err := zip.NewReader(r, size) + if err != nil { + return fmt.Errorf("zip: %w", err) + } + for _, entry := range zr.File { + if entry.FileInfo().IsDir() || strings.Contains(entry.Name, "..") || !livekitBinaryEntry(entry.Name) { + continue + } + rc, err := entry.Open() + if err != nil { + return fmt.Errorf("opening zip entry: %w", err) + } + //nolint:gosec // G110: size is bounded by the verified archive's cap + writeErr := writeExact(destPath, rc, int64(entry.UncompressedSize64)) //nolint:gosec // G115: size fits int64 + _ = rc.Close() + return writeErr + } + return fmt.Errorf("archive contains no livekit-server binary") +} + +// writeExact writes exactly size bytes from r to destPath, O_EXCL. +func writeExact(destPath string, r io.Reader, size int64) error { + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) //nolint:gosec // G304: trusted path + if err != nil { + return err + } + n, copyErr := io.Copy(out, r) + closeErr := out.Close() + if copyErr != nil { + return fmt.Errorf("writing binary: %w", copyErr) + } + if closeErr != nil { + return closeErr + } + if n != size { + return fmt.Errorf("incomplete archive entry (%d of %d bytes)", n, size) + } + return nil +} + +// parseChecksumLine finds the sha256 for filename in a goreleaser-style +// checksums file (" " per line). +func parseChecksumLine(data []byte, filename string) (string, error) { + sc := bufio.NewScanner(strings.NewReader(string(data))) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 2 && fields[1] == filename && len(fields[0]) == 64 { + return fields[0], nil + } + } + return "", fmt.Errorf("no checksum entry for %s", filename) +} + +// fetchLimited GETs url and returns at most limit bytes, erroring beyond it. +func fetchLimited(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("response exceeds %d bytes", limit) + } + return data, nil +} + +// downloadTo streams url to destPath (O_EXCL), capped at limit bytes. +func downloadTo(ctx context.Context, url, destPath string, limit int64) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) //nolint:gosec // G304: trusted path + if err != nil { + return fmt.Errorf("creating download file: %w", err) + } + n, copyErr := io.Copy(out, io.LimitReader(resp.Body, limit)) + if copyErr == nil && n == limit { + // Probe one more byte to distinguish exactly-at-limit from over-limit. + var probe [1]byte + if extra, _ := resp.Body.Read(probe[:]); extra > 0 { + copyErr = fmt.Errorf("download exceeds maximum size of %d bytes", limit) + } + } + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(destPath) + return copyErr + } + if closeErr != nil { + _ = os.Remove(destPath) + return fmt.Errorf("closing download: %w", closeErr) + } + return nil +} + +// cleanupOldLiveKitBinaries best-effort removes previously downloaded +// livekit-server versions other than keep. +func cleanupOldLiveKitBinaries(dir, keep string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + if e.Type().IsRegular() && strings.HasPrefix(name, "livekit-server-") && name != keep && + !strings.HasSuffix(name, ".download") && !strings.HasSuffix(name, ".tmp") { + if rmErr := os.Remove(filepath.Join(dir, name)); rmErr == nil { + slog.Info("livekit: removed old downloaded binary", "name", name) + } + } + } +} diff --git a/Server/ws/livekit_download_test.go b/Server/ws/livekit_download_test.go new file mode 100644 index 00000000..30ba16f1 --- /dev/null +++ b/Server/ws/livekit_download_test.go @@ -0,0 +1,259 @@ +package ws + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +func TestLivekitAssetName(t *testing.T) { + cases := []struct { + goos, goarch string + want string + wantErr bool + }{ + {"linux", "amd64", "livekit_1.13.5_linux_amd64.tar.gz", false}, + {"linux", "arm64", "livekit_1.13.5_linux_arm64.tar.gz", false}, + {"linux", "arm", "livekit_1.13.5_linux_armv7.tar.gz", false}, + {"windows", "amd64", "livekit_1.13.5_windows_amd64.zip", false}, + {"windows", "arm64", "livekit_1.13.5_windows_arm64.zip", false}, + {"darwin", "arm64", "", true}, + {"linux", "riscv64", "", true}, + } + for _, tc := range cases { + got, err := livekitAssetName("1.13.5", tc.goos, tc.goarch) + if tc.wantErr { + if err == nil { + t.Errorf("%s/%s: expected error, got %q", tc.goos, tc.goarch, got) + } + continue + } + if err != nil { + t.Errorf("%s/%s: unexpected error: %v", tc.goos, tc.goarch, err) + continue + } + if got != tc.want { + t.Errorf("%s/%s = %q, want %q", tc.goos, tc.goarch, got, tc.want) + } + } +} + +func TestParseChecksumLine(t *testing.T) { + hash := strings.Repeat("ab", 32) + data := []byte("# comment\n" + hash + " livekit_1.13.5_linux_amd64.tar.gz\ndeadbeef other.txt\n") + got, err := parseChecksumLine(data, "livekit_1.13.5_linux_amd64.tar.gz") + if err != nil { + t.Fatalf("parseChecksumLine: %v", err) + } + if got != hash { + t.Errorf("hash = %q, want %q", got, hash) + } + if _, err := parseChecksumLine(data, "missing.tar.gz"); err == nil { + t.Error("expected error for missing entry") + } + // "deadbeef" is not 64 hex chars — must not be accepted for other.txt. + if _, err := parseChecksumLine(data, "other.txt"); err == nil { + t.Error("expected error for malformed hash length") + } +} + +// makeTarGz builds a tar.gz containing LICENSE plus a livekit-server entry. +func makeTarGz(t *testing.T, binaryContent []byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for _, f := range []struct { + name string + body []byte + }{ + {"LICENSE", []byte("apache 2.0")}, + {"livekit-server", binaryContent}, + } { + if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o755, Size: int64(len(f.body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write(f.body); err != nil { + t.Fatalf("tar write: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +func TestExtractLiveKitFromZip(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range []struct { + name string + body []byte + }{ + {"LICENSE", []byte("apache 2.0")}, + {"livekit-server.exe", []byte("MZ fake windows binary")}, + } { + w, err := zw.Create(f.name) + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write(f.body); err != nil { + t.Fatalf("zip write: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + + dest := filepath.Join(t.TempDir(), "livekit-server.exe") + if err := extractLiveKitFromZip(bytes.NewReader(buf.Bytes()), int64(buf.Len()), dest); err != nil { + t.Fatalf("extractLiveKitFromZip: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("reading extracted binary: %v", err) + } + if string(got) != "MZ fake windows binary" { + t.Errorf("extracted content = %q", got) + } +} + +// serveLiveKitRelease returns an httptest server mimicking the GitHub release +// download layout for the given archive bytes, plus a request counter. +func serveLiveKitRelease(t *testing.T, version string, archive []byte, checksumOverride string) (*httptest.Server, *atomic.Int32) { + t.Helper() + asset, err := livekitAssetName(version, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("platform unsupported for auto-download: %v", err) + } + sum := sha256.Sum256(archive) + hash := hex.EncodeToString(sum[:]) + if checksumOverride != "" { + hash = checksumOverride + } + var archiveRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/v"+version+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s\n", hash, asset) + }) + mux.HandleFunc("/v"+version+"/"+asset, func(w http.ResponseWriter, _ *http.Request) { + archiveRequests.Add(1) + _, _ = w.Write(archive) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, &archiveRequests +} + +func TestEnsureLiveKitBinary_DownloadsVerifiesAndCaches(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test builds a tar.gz; windows uses the zip path (covered by TestExtractLiveKitFromZip)") + } + const version = "9.9.9-test" + binary := []byte("#!/bin/sh\necho fake livekit\n") + archive := makeTarGz(t, binary) + srv, archiveRequests := serveLiveKitRelease(t, version, archive, "") + + oldBase := livekitDownloadBase + livekitDownloadBase = srv.URL + defer func() { livekitDownloadBase = oldBase }() + + dataDir := t.TempDir() + path, err := EnsureLiveKitBinary(context.Background(), dataDir, version) + if err != nil { + t.Fatalf("EnsureLiveKitBinary: %v", err) + } + want := filepath.Join(dataDir, "livekit", livekitBinaryFilename(version)) + if path != want { + t.Errorf("path = %q, want %q", path, want) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading binary: %v", err) + } + if !bytes.Equal(got, binary) { + t.Error("extracted binary content mismatch") + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Error("binary is not executable") + } + + // Second call must hit the cache, not the network. + if _, err := EnsureLiveKitBinary(context.Background(), dataDir, version); err != nil { + t.Fatalf("cached EnsureLiveKitBinary: %v", err) + } + if n := archiveRequests.Load(); n != 1 { + t.Errorf("archive downloaded %d times, want 1 (second call must use cache)", n) + } +} + +func TestEnsureLiveKitBinary_ChecksumMismatchRejects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz path") + } + const version = "9.9.8-test" + archive := makeTarGz(t, []byte("evil")) + srv, _ := serveLiveKitRelease(t, version, archive, strings.Repeat("00", 32)) + + oldBase := livekitDownloadBase + livekitDownloadBase = srv.URL + defer func() { livekitDownloadBase = oldBase }() + + dataDir := t.TempDir() + if _, err := EnsureLiveKitBinary(context.Background(), dataDir, version); err == nil { + t.Fatal("expected checksum mismatch error") + } + if _, err := os.Stat(filepath.Join(dataDir, "livekit", livekitBinaryFilename(version))); !os.IsNotExist(err) { + t.Error("binary staged despite checksum mismatch") + } +} + +func TestEnsureLiveKitBinary_CleansUpOldVersions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz path") + } + const version = "9.9.7-test" + archive := makeTarGz(t, []byte("new build")) + srv, _ := serveLiveKitRelease(t, version, archive, "") + + oldBase := livekitDownloadBase + livekitDownloadBase = srv.URL + defer func() { livekitDownloadBase = oldBase }() + + dataDir := t.TempDir() + lkDir := filepath.Join(dataDir, "livekit") + if err := os.MkdirAll(lkDir, 0o750); err != nil { + t.Fatal(err) + } + stale := filepath.Join(lkDir, "livekit-server-1.0.0-old") + if err := os.WriteFile(stale, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := EnsureLiveKitBinary(context.Background(), dataDir, version); err != nil { + t.Fatalf("EnsureLiveKitBinary: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Error("stale downloaded binary was not cleaned up") + } +} diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 28560f5f..e3c6c268 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -139,10 +139,13 @@ logging: return cfgPath, nil } -// Start launches the livekit-server binary. If LiveKitBinaryPath is empty, -// this is a no-op (assumes LiveKit is managed externally). +// Start launches the livekit-server binary. If LiveKitBinaryPath is empty +// and auto-download is disabled, this is a no-op (assumes LiveKit is managed +// externally). With auto-download enabled, the binary is fetched (and +// checksum-verified) in the background before the process starts, so server +// startup is never blocked on the download. func (p *LiveKitProcess) Start() error { - if p.cfg.LiveKitBinaryPath == "" { + if p.cfg.LiveKitBinaryPath == "" && !p.cfg.AutoDownloadLiveKit { slog.Info("livekit: no binary path configured, assuming externally managed") return nil } @@ -163,23 +166,68 @@ func (p *LiveKitProcess) Start() error { p.cancel = cancel p.loopDone = make(chan struct{}) - go p.runLoop(ctx, cfgPath) + go func() { + defer func() { + p.mu.Lock() + if p.loopDone != nil { + close(p.loopDone) + } + p.mu.Unlock() + }() + + binPath := p.cfg.LiveKitBinaryPath + if binPath == "" { + resolved, dlErr := p.resolveBinary(ctx) + if dlErr != nil { + if ctx.Err() == nil { + slog.Error("livekit: auto-download failed — voice stays offline until livekit-server is available", + "error", dlErr, + "hint", "check the network, or set voice.livekit_binary in config.yaml to a binary you provide") + } + return + } + binPath = resolved + } + p.runLoop(ctx, cfgPath, binPath) + }() return nil } +// resolveBinary downloads (or reuses a cached copy of) the pinned +// livekit-server release, retrying a few times so a transient network hiccup +// at boot does not permanently disable voice until the next restart. +func (p *LiveKitProcess) resolveBinary(ctx context.Context) (string, error) { + const ( + attempts = 3 + retryDelay = 15 * time.Second + ) + var lastErr error + for i := range attempts { + if i > 0 { + slog.Warn("livekit: retrying download", "attempt", i+1, "error", lastErr) + select { + case <-time.After(retryDelay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + path, err := EnsureLiveKitBinary(attemptCtx, p.dataDir, p.cfg.LiveKitVersion) + cancel() + if err == nil { + return path, nil + } + lastErr = err + } + return "", lastErr +} + // runLoop starts and restarts the process until stopped or context cancelled. // Uses exponential backoff (3s → 6s → 12s … up to 60s) and stops after 10 -// consecutive rapid failures (process exits within 30 seconds). -func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { - defer func() { - p.mu.Lock() - if p.loopDone != nil { - close(p.loopDone) - } - p.mu.Unlock() - }() - +// consecutive rapid failures (process exits within 30 seconds). loopDone is +// closed by the Start goroutine that calls this. +func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath, binPath string) { const ( baseDelay = 3 * time.Second maxDelay = 60 * time.Second @@ -195,7 +243,7 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { return } - cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath) //nolint:gosec // G204: binary path from trusted server config + cmd := exec.CommandContext(ctx, binPath, "--config", cfgPath) //nolint:gosec // G204: binary path from trusted server config or verified download cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.WaitDelay = 6 * time.Second // bound Wait to prevent goroutine leak on Windows @@ -210,7 +258,7 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { p.mu.Unlock() slog.Info("livekit: starting process", - "binary", p.cfg.LiveKitBinaryPath, + "binary", binPath, "config", cfgPath, "rapid_failures", rapidFailures) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 1851bc86..a19b2ebd 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -167,6 +167,10 @@ type channelPayload struct { Category string `json:"category"` Topic string `json:"topic"` Position int `json:"position"` + // SlowMode lets the client pre-disable the composer for the cooldown + // instead of accepting a message the server will refuse with SLOW_MODE. + // Seconds; 0 means off. + SlowMode int `json:"slow_mode"` } type channelDeletePayload struct { @@ -480,6 +484,7 @@ func buildChannelCreate(ch *db.Channel) []byte { Category: ch.Category, Topic: ch.Topic, Position: ch.Position, + SlowMode: ch.SlowMode, }, }) } @@ -495,6 +500,7 @@ func buildChannelUpdate(ch *db.Channel) []byte { Category: ch.Category, Topic: ch.Topic, Position: ch.Position, + SlowMode: ch.SlowMode, }, }) } diff --git a/Server/ws/perm_cache_test.go b/Server/ws/perm_cache_test.go new file mode 100644 index 00000000..ddde317b --- /dev/null +++ b/Server/ws/perm_cache_test.go @@ -0,0 +1,127 @@ +package ws_test + +// perm_cache_test.go — Phase 2 (ws permission cache) coverage: the ws-side +// permission gates now answer from service.PermissionService. Two invariants +// are locked here: +// 1. A role change performed through the invalidating path (the DB write plus +// InvalidateUser, exactly what admin/handlers_users.go does) is visible to +// the very next ws permission check — no cache-TTL wait. +// 2. The cache is actually consulted: a second check for the same user does +// not re-read the role from the store. + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/service" + "github.com/owncord/server/ws" +) + +// voiceJoinRaw builds a raw voice_join envelope for channelID. +func voiceJoinRaw(channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": channelID}, + }) + return raw +} + +// TestPermCache_RoleChangeInvalidationIsImmediate drives the voice_join +// CONNECT_VOICE gate (a cached ws-side check) before and after a role change +// that mirrors the admin handler: DB write, then InvalidateUser. The demotion +// must take effect on the immediately following check, with no TTL wait. +// LiveKit is deliberately not configured, so a PASSED gate surfaces as +// VOICE_ERROR ("voice is not configured") instead of FORBIDDEN — which lets +// the test observe the gate verdict without a real SFU. +func TestPermCache_RoleChangeInvalidationIsImmediate(t *testing.T) { + database := openHandlerDB(t) + limiter := auth.NewRateLimiter() + svc := service.New(database, limiter) + hub := ws.NewHub(database, limiter, svc) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + + user := seedMemberUser(t, database, "cache-demote") // Member: has CONNECT_VOICE + chID := seedTestChannel(t, database, "cache-demote-vc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.RegisterNowForTest(c) + + // First join: the gate passes (and populates the user's cache entry); the + // join then fails on the unconfigured LiveKit, not on permissions. + hub.HandleMessageForTest(c, voiceJoinRaw(chID)) + if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" { + t.Fatalf("member with CONNECT_VOICE was denied the voice_join gate (code %q)", code) + } + + // Demote through the invalidating path, mirroring admin/handlers_users.go: + // role write first, then InvalidateUser. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (150, 'cache-novoice', NULL, ?, 5, 0)`, + permissions.ReadMessages, + ); err != nil { + t.Fatalf("seed novoice role: %v", err) + } + if _, err := database.ExecContext(context.Background(), + `UPDATE users SET role_id = 150 WHERE id = ?`, user.ID); err != nil { + t.Fatalf("reassign user role: %v", err) + } + svc.Permissions.InvalidateUser(user.ID) + + // The very next check must see the demoted role — cached entry gone. + hub.HandleMessageForTest(c, voiceJoinRaw(chID)) + if code := receiveErrorCode(send, 300*time.Millisecond); code != "FORBIDDEN" { + t.Fatalf("demoted user passed the CONNECT_VOICE gate right after InvalidateUser, got code %q, want FORBIDDEN", code) + } +} + +// countingStore wraps a service.Store and counts GetRoleForUser calls — the +// query every uncached ws-side permission check must issue. +type countingStore struct { + service.Store + roleLookups atomic.Int64 +} + +func (s *countingStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) { + s.roleLookups.Add(1) + return s.Store.GetRoleForUser(ctx, userID) +} + +// TestPermCache_SecondCheckServedFromCache proves the ws gates actually use the +// cache: two consecutive voice_join permission checks for the same user issue +// exactly one role lookup against the store — the second is a cache hit. +func TestPermCache_SecondCheckServedFromCache(t *testing.T) { + database := openHandlerDB(t) + limiter := auth.NewRateLimiter() + store := &countingStore{Store: database} + svc := service.New(store, limiter) + hub := ws.NewHub(database, limiter, svc) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + + user := seedMemberUser(t, database, "cache-hit") + chID := seedTestChannel(t, database, "cache-hit-vc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.RegisterNowForTest(c) + + for i := range 2 { + hub.HandleMessageForTest(c, voiceJoinRaw(chID)) + if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" { + t.Fatalf("join %d: member with CONNECT_VOICE was denied the gate", i+1) + } + } + + if got := store.roleLookups.Load(); got != 1 { + t.Fatalf("role lookups through the permission service = %d, want 1 (second check must be a cache hit)", got) + } +} diff --git a/Server/ws/pubsub.go b/Server/ws/pubsub.go index e9fab1bd..840dc1dc 100644 --- a/Server/ws/pubsub.go +++ b/Server/ws/pubsub.go @@ -1,7 +1,6 @@ package ws import ( - "fmt" "strconv" "strings" "sync" @@ -19,9 +18,18 @@ type Topic string // TopicGlobal is the well-known topic every client subscribes to on connect. const TopicGlobal Topic = "global" +// topicFor builds "" via strconv.AppendInt — topics are built on +// every broadcast and subscription change, so skip fmt.Sprintf's overhead. +func topicFor(prefix string, id int64) Topic { + b := make([]byte, 0, len(prefix)+20) + b = append(b, prefix...) + b = strconv.AppendInt(b, id, 10) + return Topic(b) +} + // ChannelTopic returns the topic for a text channel. func ChannelTopic(channelID int64) Topic { - return Topic(fmt.Sprintf("channel:%d", channelID)) + return topicFor("channel:", channelID) } // channelTopicID is the inverse of ChannelTopic: it returns the channel ID @@ -40,12 +48,12 @@ func channelTopicID(t Topic) int64 { // VoiceTopic returns the topic for a voice channel. func VoiceTopic(channelID int64) Topic { - return Topic(fmt.Sprintf("voice:%d", channelID)) + return topicFor("voice:", channelID) } // UserTopic returns the per-user topic for DMs and mentions. func UserTopic(userID int64) Topic { - return Topic(fmt.Sprintf("user:%d", userID)) + return topicFor("user:", userID) } // PubSub provides topic-based publish/subscribe routing for WebSocket clients. diff --git a/Server/ws/serve.go b/Server/ws/serve.go index c9d0ff87..bcf17620 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -2,7 +2,6 @@ package ws import ( "context" - "encoding/json" "fmt" "log/slog" "net/http" @@ -11,7 +10,6 @@ import ( "github.com/coder/websocket" - "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/permissions" @@ -234,14 +232,15 @@ func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) } - // Include the user's open DM channels. - dmChannels, dmErr := database.GetUserDMChannels(ctx, user.ID) + // 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. + dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID) if dmErr != nil { - slog.Warn("computeAllowedChannels GetUserDMChannels", "err", dmErr) + slog.Warn("computeAllowedChannels GetUserDMChannelIDs", "err", dmErr) // Non-fatal: DM events will simply be filtered out. } else { - for i := range dmChannels { - allowed[dmChannels[i].ChannelID] = true + for _, id := range dmIDs { + allowed[id] = true } } @@ -351,399 +350,3 @@ func (h *Hub) handleFreshConnect( return nil } - -// writePump drains the client's send channels and writes to the WebSocket. -// Priority ordering: high > normal > low. High-priority messages (DMs, mentions) -// are drained first. Normal messages (chat, reactions) come next. Low-priority -// messages (typing, presence) are only sent when no higher-priority work is pending. -func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { - writeMsg := func(msg []byte) bool { - wCtx, cancel := context.WithTimeout(ctx, writeTimeout) - err := conn.Write(wCtx, websocket.MessageText, msg) - cancel() - if err != nil { - slog.Warn("ws writePump error", "user_id", c.userID, "err", err) - return false - } - return true - } - - for { - // Priority 1: drain all pending high-priority messages first. - select { - case msg, ok := <-c.sendHigh: - if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") - return - } - if !writeMsg(msg) { - return - } - continue - 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). - select { - case msg, ok := <-c.sendHigh: - if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") - return - } - if !writeMsg(msg) { - return - } - case msg, ok := <-c.send: - if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") - return - } - if !writeMsg(msg) { - return - } - case msg, ok := <-c.sendLow: - if !ok { - _ = conn.Close(websocket.StatusNormalClosure, "") - return - } - if !writeMsg(msg) { - return - } - case <-ctx.Done(): - return - } - } -} - -// readPump reads from the WebSocket and dispatches messages. Blocks until disconnect. -func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { - var lastReadErr error - defer func() { - // The connection is gone, so ctx is (or is about to be) cancelled. - // Teardown DB writes must still complete — a dead connection must not - // cancel its own cleanup — so detach cancellation but keep values. - cleanupCtx := context.WithoutCancel(ctx) - // Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections. - voiceChID := c.getVoiceChID() - replaced := hub.unregisterNow(c) - if c.user != nil { - // Clean up voice state only when this was the user's final - // connection. A replacement connection owns the (transferred) - // voice session, and the join_token guard cannot tell the - // difference — the transfer keeps the same joined_at — so - // cleaning here would delete the replacement's DB row whenever - // teardown snapshots voiceChID before the transfer zeroes it. - if voiceChID != 0 && !replaced { - hub.handleVoiceLeave(cleanupCtx, c) - } - c.mu.Lock() - received := c.msgsReceived - sent := c.msgsSent - dropped := c.msgsDropped - c.mu.Unlock() - duration := time.Since(c.connectedAt) - - attrs := []any{ - "username", c.user.Username, - "user_id", c.userID, - "remote", c.remoteAddr, - "duration_s", int64(duration.Seconds()), - "msgs_received", received, - "msgs_sent", sent, - "msgs_dropped", dropped, - } - if voiceChID > 0 { - attrs = append(attrs, "voice_channel_id", voiceChID) - } - if replaced { - attrs = append(attrs, "replaced", true) - } - if lastReadErr != nil { - attrs = append(attrs, "last_error", lastReadErr.Error()) - } - slog.Info("websocket disconnected", attrs...) - - if !replaced { - _ = hub.db.UpdateUserStatus(cleanupCtx, c.userID, "offline") - hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) - } - } - }() - - for { - _, msg, err := conn.Read(ctx) - if err != nil { - lastReadErr = err - return - } - c.touch() - hub.handleMessage(c, msg) - } -} - -// 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) { - ctx, cancel := context.WithTimeout(parent, authDeadline) - defer cancel() - - _, raw, err := conn.Read(ctx) - if err != nil { - return nil, "", 0, 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) - } - if env.Type != "auth" { - _ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth")) - return nil, "", 0, fmt.Errorf("auth: unexpected type %q", env.Type) - } - - var p struct { - Token string `json:"token"` - LastSeq uint64 `json:"last_seq"` - } - 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") - } - - hash := auth.HashToken(p.Token) - sess, err := database.GetSessionByTokenHash(ctx, hash) - if err != nil || sess == nil { - _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) - 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, "", 0, 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") - } - - 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, "", 0, 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 user, hash, p.LastSeq, nil -} - -// buildAuthOK constructs the auth_ok server→client message. -// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status). -// -// replaySource records which reconnection tier served this client: -// - "none" — fresh connection or full re-sync (no resume) -// - "buffer" — resume served from the in-memory ring buffer -// - "db" — resume served from the persistent EventStore (Phase B Step 7) -func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, replaySource string) []byte { - var avatarVal any - if user.Avatar != nil { - avatarVal = *user.Avatar - } - - serverName, motd := h.getCachedSettings(ctx) - - return buildJSON(map[string]any{ - "type": MsgTypeAuthOK, - "payload": map[string]any{ - "user": map[string]any{ - "id": user.ID, - "username": user.Username, - "avatar": avatarVal, - "role": roleName, - }, - "server_name": serverName, - "motd": motd, - "replay_source": replaySource, - }, - }) -} - -// channelRefs maps db channels to the checker's db-agnostic ChannelRef so -// buildReady and computeAllowedChannels can share permissions.VisibleChannelIDs. -func channelRefs(channels []db.Channel) []permissions.ChannelRef { - refs := make([]permissions.ChannelRef, len(channels)) - for i := range channels { - refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type} - } - return refs -} - -// permOverrides maps a db override map to the checker's override map. -func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions.ChannelOverride { - out := make(map[int64]permissions.ChannelOverride, len(overrides)) - for id, o := range overrides { - out[id] = permissions.ChannelOverride{Allow: o.Allow, Deny: o.Deny} - } - return out -} - -// channelCanSend reports whether a user with the given role and per-channel -// override may post in a channel of chanType. It mirrors the non-DM branch of -// MessageService.checkSendPermission so the client can pre-disable the composer -// without a round-trip; the server still enforces the rule authoritatively. -func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { - if role == nil { - return false - } - if permissions.HasAdmin(role.Permissions) { - return true - } - eff := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) - need := permissions.ReadMessages | permissions.SendMessages - if eff&need != need { - return false - } - if chanType == "announcement" { - return eff&permissions.ManageMessages == permissions.ManageMessages - } - return true -} - -// 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). -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 { - return nil, fmt.Errorf("buildReady ListChannels: %w", err) - } - roles, err := database.ListRoles(ctx) - if err != nil { - return nil, fmt.Errorf("buildReady ListRoles: %w", err) - } - - members, err := database.ListMembers(ctx) - if err != nil { - slog.Warn("buildReady ListMembers", "err", err) - members = []db.MemberSummary{} - } - - // Filter channels by READ_MESSAGES through the single permissions.Checker - // predicate shared with REST ListVisibleChannels and reconnect replay - // filtering (computeAllowedChannels). The overrides map is fetched once and - // reused below for the per-channel can_send affordance. DM channels are - // excluded by the checker — they are delivered via the dm_channels field. - overrides := map[int64]db.ChannelOverride{} - if role != nil && !permissions.HasAdmin(role.Permissions) { - var oErr error - overrides, oErr = database.GetAllChannelPermissionsForRole(ctx, role.ID) - if oErr != nil { - return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr) - } - } - var visibleChannels []db.Channel - if role != nil { - // Nil role = zero access (fail closed), handled by skipping the filter. - visibleIDs := h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) - for i := range channels { - if visibleIDs[channels[i].ID] { - visibleChannels = append(visibleChannels, channels[i]) - } - } - } - if visibleChannels == nil { - visibleChannels = []db.Channel{} - } - - // Per-user unread counts. - unreadMap, err := database.GetChannelUnreadCounts(ctx, userID) - if err != nil { - slog.Warn("buildReady GetChannelUnreadCounts", "err", err) - unreadMap = map[int64]db.ChannelUnread{} - } - - // Build protocol-compliant channel objects (strip extra fields). - channelPayloads := make([]map[string]any, 0, len(visibleChannels)) - for i := range visibleChannels { - entry := map[string]any{ - "id": visibleChannels[i].ID, - "name": visibleChannels[i].Name, - "type": visibleChannels[i].Type, - "category": visibleChannels[i].Category, - "position": visibleChannels[i].Position, - // can_send drives the client's composer affordance. It mirrors - // MessageService.checkSendPermission for non-DM channels: base role - // ± channel overrides must grant READ|SEND, and announcement - // channels additionally require MANAGE_MESSAGES; admins bypass. The - // server remains the authority — this only pre-disables the UI. - "can_send": channelCanSend(role, overrides[visibleChannels[i].ID], visibleChannels[i].Type), - } - if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" { - if u, ok := unreadMap[visibleChannels[i].ID]; ok { - entry["unread_count"] = u.UnreadCount - entry["last_message_id"] = u.LastMessageID - } else { - entry["unread_count"] = 0 - entry["last_message_id"] = 0 - } - } - 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. - dmChannels, err := database.GetUserDMChannels(ctx, userID) - if err != nil { - slog.Warn("buildReady GetUserDMChannels", "err", err) - dmChannels = []db.DMChannelInfo{} - } - - serverName, motd := h.getCachedSettings(ctx) - - return buildJSON(map[string]any{ - "type": MsgTypeReady, - "payload": map[string]any{ - "channels": channelPayloads, - "members": members, - "voice_states": voiceStates, - "roles": roles, - "dm_channels": dmChannels, - "server_name": serverName, - "motd": motd, - }, - }), nil -} - -// collectAllVoiceStates gathers voice states across all channels in a single -// query, replacing the previous N+1 per-channel pattern. -func collectAllVoiceStates(ctx context.Context, database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { - return database.GetAllVoiceStates(ctx) -} diff --git a/Server/ws/serve_auth.go b/Server/ws/serve_auth.go new file mode 100644 index 00000000..b7e44e8b --- /dev/null +++ b/Server/ws/serve_auth.go @@ -0,0 +1,77 @@ +package ws + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/coder/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// 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) { + ctx, cancel := context.WithTimeout(parent, authDeadline) + defer cancel() + + _, raw, err := conn.Read(ctx) + if err != nil { + return nil, "", 0, 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) + } + if env.Type != "auth" { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth")) + return nil, "", 0, fmt.Errorf("auth: unexpected type %q", env.Type) + } + + var p struct { + Token string `json:"token"` + LastSeq uint64 `json:"last_seq"` + } + 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") + } + + hash := auth.HashToken(p.Token) + sess, err := database.GetSessionByTokenHash(ctx, hash) + if err != nil || sess == nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) + 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, "", 0, 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") + } + + 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, "", 0, 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 user, hash, p.LastSeq, nil +} diff --git a/Server/ws/serve_pumps.go b/Server/ws/serve_pumps.go new file mode 100644 index 00000000..892373cf --- /dev/null +++ b/Server/ws/serve_pumps.go @@ -0,0 +1,140 @@ +package ws + +import ( + "context" + "log/slog" + "time" + + "github.com/coder/websocket" +) + +// writePump drains the client's send channels and writes to the WebSocket. +// Priority ordering: high > normal > low. High-priority messages (DMs, mentions) +// are drained first. Normal messages (chat, reactions) come next. Low-priority +// messages (typing, presence) are only sent when no higher-priority work is pending. +func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { + writeMsg := func(msg []byte) bool { + wCtx, cancel := context.WithTimeout(ctx, writeTimeout) + err := conn.Write(wCtx, websocket.MessageText, msg) + cancel() + if err != nil { + slog.Warn("ws writePump error", "user_id", c.userID, "err", err) + return false + } + return true + } + + for { + // Priority 1: drain all pending high-priority messages first. + select { + case msg, ok := <-c.sendHigh: + if !ok { + _ = conn.Close(websocket.StatusNormalClosure, "") + return + } + if !writeMsg(msg) { + return + } + continue + 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). + select { + case msg, ok := <-c.sendHigh: + if !ok { + _ = conn.Close(websocket.StatusNormalClosure, "") + return + } + if !writeMsg(msg) { + return + } + case msg, ok := <-c.send: + if !ok { + _ = conn.Close(websocket.StatusNormalClosure, "") + return + } + if !writeMsg(msg) { + return + } + case msg, ok := <-c.sendLow: + if !ok { + _ = conn.Close(websocket.StatusNormalClosure, "") + return + } + if !writeMsg(msg) { + return + } + case <-ctx.Done(): + return + } + } +} + +// readPump reads from the WebSocket and dispatches messages. Blocks until disconnect. +func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { + var lastReadErr error + defer func() { + // The connection is gone, so ctx is (or is about to be) cancelled. + // Teardown DB writes must still complete — a dead connection must not + // cancel its own cleanup — so detach cancellation but keep values. + cleanupCtx := context.WithoutCancel(ctx) + // Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections. + voiceChID := c.getVoiceChID() + replaced := hub.unregisterNow(c) + if c.user != nil { + // Clean up voice state only when this was the user's final + // connection. A replacement connection owns the (transferred) + // voice session, and the join_token guard cannot tell the + // difference — the transfer keeps the same joined_at — so + // cleaning here would delete the replacement's DB row whenever + // teardown snapshots voiceChID before the transfer zeroes it. + if voiceChID != 0 && !replaced { + hub.handleVoiceLeave(cleanupCtx, c) + } + c.mu.Lock() + received := c.msgsReceived + sent := c.msgsSent + dropped := c.msgsDropped + c.mu.Unlock() + duration := time.Since(c.connectedAt) + + attrs := []any{ + "username", c.user.Username, + "user_id", c.userID, + "remote", c.remoteAddr, + "duration_s", int64(duration.Seconds()), + "msgs_received", received, + "msgs_sent", sent, + "msgs_dropped", dropped, + } + if voiceChID > 0 { + attrs = append(attrs, "voice_channel_id", voiceChID) + } + if replaced { + attrs = append(attrs, "replaced", true) + } + if lastReadErr != nil { + attrs = append(attrs, "last_error", lastReadErr.Error()) + } + slog.Info("websocket disconnected", attrs...) + + if !replaced { + _ = hub.db.UpdateUserStatus(cleanupCtx, c.userID, "offline") + hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) + } + } + }() + + for { + _, msg, err := conn.Read(ctx) + if err != nil { + lastReadErr = err + return + } + c.touch() + hub.handleMessage(c, msg) + } +} diff --git a/Server/ws/serve_ready.go b/Server/ws/serve_ready.go new file mode 100644 index 00000000..38d53b42 --- /dev/null +++ b/Server/ws/serve_ready.go @@ -0,0 +1,214 @@ +package ws + +import ( + "context" + "fmt" + "log/slog" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// buildAuthOK constructs the auth_ok server→client message. +// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status). +// +// replaySource records which reconnection tier served this client: +// - "none" — fresh connection or full re-sync (no resume) +// - "buffer" — resume served from the in-memory ring buffer +// - "db" — resume served from the persistent EventStore (Phase B Step 7) +func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, replaySource string) []byte { + var avatarVal any + if user.Avatar != nil { + avatarVal = *user.Avatar + } + + serverName, motd := h.getCachedSettings(ctx) + + return buildJSON(map[string]any{ + "type": MsgTypeAuthOK, + "payload": map[string]any{ + "user": map[string]any{ + "id": user.ID, + "username": user.Username, + "avatar": avatarVal, + "role": roleName, + }, + "server_name": serverName, + "motd": motd, + "replay_source": replaySource, + }, + }) +} + +// channelRefs maps db channels to the checker's db-agnostic ChannelRef so +// buildReady and computeAllowedChannels can share permissions.VisibleChannelIDs. +func channelRefs(channels []db.Channel) []permissions.ChannelRef { + refs := make([]permissions.ChannelRef, len(channels)) + for i := range channels { + refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type} + } + return refs +} + +// permOverrides maps a db override map to the checker's override map. +func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions.ChannelOverride { + out := make(map[int64]permissions.ChannelOverride, len(overrides)) + for id, o := range overrides { + out[id] = permissions.ChannelOverride{Allow: o.Allow, Deny: o.Deny} + } + return out +} + +// channelCanSend reports whether a user with the given role and per-channel +// override may post in a channel of chanType. It mirrors the non-DM branch of +// MessageService.checkSendPermission so the client can pre-disable the composer +// without a round-trip; the server still enforces the rule authoritatively. +func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { + if role == nil { + return false + } + if permissions.HasAdmin(role.Permissions) { + return true + } + eff := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) + need := permissions.ReadMessages | permissions.SendMessages + if eff&need != need { + return false + } + if chanType == "announcement" { + return eff&permissions.ManageMessages == permissions.ManageMessages + } + return true +} + +// 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). +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 { + return nil, fmt.Errorf("buildReady ListChannels: %w", err) + } + roles, err := database.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("buildReady ListRoles: %w", err) + } + + members, err := database.ListMembers(ctx) + if err != nil { + slog.Warn("buildReady ListMembers", "err", err) + members = []db.MemberSummary{} + } + + // Filter channels by READ_MESSAGES through the single permissions.Checker + // predicate shared with REST ListVisibleChannels and reconnect replay + // filtering (computeAllowedChannels). The overrides map is fetched once and + // reused below for the per-channel can_send affordance. DM channels are + // excluded by the checker — they are delivered via the dm_channels field. + overrides := map[int64]db.ChannelOverride{} + if role != nil && !permissions.HasAdmin(role.Permissions) { + var oErr error + overrides, oErr = database.GetAllChannelPermissionsForRole(ctx, role.ID) + if oErr != nil { + return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr) + } + } + var visibleChannels []db.Channel + if role != nil { + // Nil role = zero access (fail closed), handled by skipping the filter. + visibleIDs := h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) + for i := range channels { + if visibleIDs[channels[i].ID] { + visibleChannels = append(visibleChannels, channels[i]) + } + } + } + if visibleChannels == nil { + visibleChannels = []db.Channel{} + } + + // Per-user unread counts. + unreadMap, err := database.GetChannelUnreadCounts(ctx, userID) + if err != nil { + slog.Warn("buildReady GetChannelUnreadCounts", "err", err) + unreadMap = map[int64]db.ChannelUnread{} + } + + // Build protocol-compliant channel objects (strip extra fields). + channelPayloads := make([]map[string]any, 0, len(visibleChannels)) + for i := range visibleChannels { + entry := map[string]any{ + "id": visibleChannels[i].ID, + "name": visibleChannels[i].Name, + "type": visibleChannels[i].Type, + "category": visibleChannels[i].Category, + "position": visibleChannels[i].Position, + // can_send drives the client's composer affordance. It mirrors + // MessageService.checkSendPermission for non-DM channels: base role + // ± channel overrides must grant READ|SEND, and announcement + // channels additionally require MANAGE_MESSAGES; admins bypass. The + // server remains the authority — this only pre-disables the UI. + "can_send": channelCanSend(role, overrides[visibleChannels[i].ID], visibleChannels[i].Type), + // Cooldown in seconds (0 = off). Lets the composer disable itself + // for the window instead of accepting a send the server refuses + // with SLOW_MODE. The server still enforces. + "slow_mode": visibleChannels[i].SlowMode, + } + if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" { + if u, ok := unreadMap[visibleChannels[i].ID]; ok { + entry["unread_count"] = u.UnreadCount + entry["last_message_id"] = u.LastMessageID + } else { + entry["unread_count"] = 0 + entry["last_message_id"] = 0 + } + } + 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. + dmChannels, err := database.GetUserDMChannels(ctx, userID) + if err != nil { + slog.Warn("buildReady GetUserDMChannels", "err", err) + dmChannels = []db.DMChannelInfo{} + } + + serverName, motd := h.getCachedSettings(ctx) + + return buildJSON(map[string]any{ + "type": MsgTypeReady, + "payload": map[string]any{ + "channels": channelPayloads, + "members": members, + "voice_states": voiceStates, + "roles": roles, + "dm_channels": dmChannels, + "server_name": serverName, + "motd": motd, + }, + }), nil +} + +// collectAllVoiceStates gathers voice states across all channels in a single +// query, replacing the previous N+1 per-channel pattern. +func collectAllVoiceStates(ctx context.Context, database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { + return database.GetAllVoiceStates(ctx) +} diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go index a2768f2f..2500f2f8 100644 --- a/Server/ws/serve_test.go +++ b/Server/ws/serve_test.go @@ -557,12 +557,13 @@ func TestHub_BroadcastServerRestart_DeliversToAllClients(t *testing.T) { u2 := seedTestUser(t, database, "restart-u2") s1 := make(chan []byte, 4) s2 := make(chan []byte, 4) + c2 := ws.NewTestClient(hub, u2, s2) hub.Register(ws.NewTestClient(hub, u1, s1)) - hub.Register(ws.NewTestClient(hub, u2, s2)) - time.Sleep(20 * time.Millisecond) + hub.Register(c2) + waitRegistered(t, hub, c2) // in-order events: both clients registered hub.BroadcastServerRestart("update", 5) - time.Sleep(20 * time.Millisecond) + // The receive selects below block with their own timeout. for _, s := range []chan []byte{s1, s2} { select { @@ -603,12 +604,13 @@ func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) { u1 := seedTestUser(t, database, "chcreate-u1") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) ch := &db.Channel{ID: 77, Name: "announcements", Type: "text", Category: "News", Position: 1} hub.BroadcastChannelCreate(ch) - time.Sleep(20 * time.Millisecond) + // The receive select below blocks with its own timeout. select { case msg := <-s1: @@ -641,12 +643,13 @@ func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) { u1 := seedTestUser(t, database, "chupdate-u1") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) ch := &db.Channel{ID: 88, Name: "updated-channel", Type: "text", Category: "General", Position: 2} hub.BroadcastChannelUpdate(ch) - time.Sleep(20 * time.Millisecond) + // The receive select below blocks with its own timeout. select { case msg := <-s1: @@ -676,11 +679,12 @@ func TestHub_BroadcastChannelDelete_DeliversToAllClients(t *testing.T) { u1 := seedTestUser(t, database, "chdel-u1") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) hub.BroadcastChannelDelete(123) - time.Sleep(20 * time.Millisecond) + // The receive select below blocks with its own timeout. select { case msg := <-s1: @@ -709,11 +713,12 @@ func TestHub_BroadcastMemberBan_DeliversToAllClients(t *testing.T) { u1 := seedTestUser(t, database, "ban-u1") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) hub.BroadcastMemberBan(999) - time.Sleep(20 * time.Millisecond) + // The receive select below blocks with its own timeout. select { case msg := <-s1: @@ -742,11 +747,12 @@ func TestHub_BroadcastMemberUpdate_DeliversToAllClients(t *testing.T) { u1 := seedTestUser(t, database, "memupdate-u1") s1 := make(chan []byte, 4) - hub.Register(ws.NewTestClient(hub, u1, s1)) - time.Sleep(20 * time.Millisecond) + c1 := ws.NewTestClient(hub, u1, s1) + hub.Register(c1) + waitRegistered(t, hub, c1) hub.BroadcastMemberUpdate(888, "moderator") - time.Sleep(20 * time.Millisecond) + // The receive select below blocks with its own timeout. select { case msg := <-s1: diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index c944b60c..39436901 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" + "github.com/owncord/server/auth" "github.com/owncord/server/permissions" ) @@ -14,7 +15,7 @@ func handleVoiceMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps a muteCmd := cmd.(VoiceMuteCmd) userID := info.UserID - ratKey := fmt.Sprintf("voice_mute:%d", userID) + ratKey := auth.Key("voice_mute", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceMuteRateLimit, voiceMuteWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many mute toggles"}} } @@ -38,7 +39,7 @@ func handleVoiceDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps deafenCmd := cmd.(VoiceDeafenCmd) userID := info.UserID - ratKey := fmt.Sprintf("voice_deafen:%d", userID) + ratKey := auth.Key("voice_deafen", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceDeafenRateLimit, voiceDeafenWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many deafen toggles"}} } @@ -63,7 +64,7 @@ func handleVoiceCameraV2(ctx context.Context, cmd Command, info ClientInfo, deps userID := info.UserID voiceChID := info.VoiceChannelID - ratKey := fmt.Sprintf("voice_camera:%d", userID) + ratKey := auth.Key("voice_camera", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many camera toggles"}} } @@ -73,7 +74,7 @@ func handleVoiceCameraV2(ctx context.Context, cmd Command, info ClientInfo, deps } // Permission check. - if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { return *r } @@ -118,7 +119,7 @@ func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, userID := info.UserID voiceChID := info.VoiceChannelID - ratKey := fmt.Sprintf("voice_screenshare:%d", userID) + ratKey := auth.Key("voice_screenshare", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many screenshare toggles"}} } @@ -128,7 +129,7 @@ func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, } // Permission check. - if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { return *r } diff --git a/Server/ws/voice_dm_access_test.go b/Server/ws/voice_dm_access_test.go index 3ad44db0..5cac6357 100644 --- a/Server/ws/voice_dm_access_test.go +++ b/Server/ws/voice_dm_access_test.go @@ -57,10 +57,9 @@ func TestVoiceJoin_DMNonParticipant_GetsNoTokenAndNoVoiceState(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, mallory, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(dmID)) - time.Sleep(50 * time.Millisecond) assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) @@ -82,10 +81,9 @@ func TestVoiceJoin_DMParticipant_StillJoins(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, alice, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(dmID)) - time.Sleep(50 * time.Millisecond) if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) { t.Error("a DM participant must still receive a voice token for their own DM") @@ -110,7 +108,7 @@ func TestVoiceTokenRefresh_DMNonParticipant_Refused(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, mallory, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Second entry point: the refresh mints a token from the session's own voice // channel id, so it must re-run the same membership check rather than trust @@ -118,7 +116,6 @@ func TestVoiceTokenRefresh_DMNonParticipant_Refused(t *testing.T) { ws.SetVoiceChIDForTest(c, dmID) hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) - time.Sleep(50 * time.Millisecond) assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) } @@ -132,14 +129,12 @@ func TestVoiceTokenRefresh_DMParticipant_StillRefreshes(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, alice, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(dmID)) - time.Sleep(50 * time.Millisecond) - drainChanBuf(send) + drainChanTimeout(send, 50*time.Millisecond) hub.HandleMessageForTest(c, voiceTokenRefreshMsg()) - time.Sleep(50 * time.Millisecond) if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) { t.Error("a DM participant must still be able to refresh their voice token") diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 6367db55..e32538f8 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -3,9 +3,10 @@ package ws import ( "context" "encoding/base64" - "fmt" "log/slog" "time" + + "github.com/owncord/server/auth" ) // Voice E2EE rate limits. Both the announce and offer relays fan out to every @@ -104,7 +105,7 @@ func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, userID := info.UserID voiceChID := info.VoiceChannelID - ratKey := fmt.Sprintf("voice_e2ee_announce:%d", userID) + ratKey := auth.Key("voice_e2ee_announce", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee announcements"}} } @@ -209,14 +210,14 @@ func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, dep // only, and sized for a whole rotation. Passing it is now the precondition // for creating any per-target entry, so limiter growth is bounded by real, // permission-checked voice joins rather than by attacker-chosen integers. - ratKey := fmt.Sprintf("voice_e2ee_offer:%d:%d", info.UserID, voiceChID) + ratKey := auth.Key(auth.Key("voice_e2ee_offer", info.UserID), voiceChID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EEOfferRateLimit, voiceE2EEWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} } // Inner budget keeps the W1-2 per-victim cap: an offer can force the target // to re-key or disconnect, so repeated offers at one peer stay capped even // though a full rotation across many peers passes. - targetKey := fmt.Sprintf("voice_e2ee_offer:%d:%d:%d", info.UserID, voiceChID, targetUserID) + targetKey := auth.Key(ratKey, targetUserID) if d.Limiter != nil && !d.Limiter.Allow(targetKey, voiceE2EERateLimit, voiceE2EEWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} } @@ -259,18 +260,15 @@ func (h *Hub) sendToUserIfInVoiceChannel(voiceChannelID, targetUserID int64, msg // sendToVoiceChannelExcept sends a message to all clients in the given voice // channel except the one identified by excludeUserID. +// +// Delivery goes through the voice pub/sub topic (joined at voice_join, +// dropped at voice_leave/disconnect) rather than scanning every connected +// client under h.mu: publishWithPriority snapshots the subscriber set and +// sends outside the lock, so this costs O(participants) and never nests +// h.mu with each client's own mutex. Key-offer delivery keeps the stricter +// TOCTOU-guarded sendToUserIfInVoiceChannel path. func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) { - h.mu.RLock() - defer h.mu.RUnlock() - - for uid, client := range h.clients { - if uid == excludeUserID { - continue - } - if client.getVoiceChID() == channelID { - client.sendMsg(msg) - } - } + h.pubsub.Publish(VoiceTopic(channelID), msg, excludeUserID) } // getClientE2EEPubKey returns the stored ECDH public key and its identity diff --git a/Server/ws/voice_e2ee_test.go b/Server/ws/voice_e2ee_test.go index 072a86f1..b80d7155 100644 --- a/Server/ws/voice_e2ee_test.go +++ b/Server/ws/voice_e2ee_test.go @@ -93,30 +93,27 @@ func TestE2EE_Offer_TargetChannelCheckAtomicWithLookup(t *testing.T) { sendCh := make(chan []byte, 32) senderClient := ws.NewTestClientWithUser(hub, sender, 0, sendCh) hub.Register(senderClient) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, senderClient) hub.HandleMessageForTest(senderClient, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(sendCh) + drainChanTimeout(sendCh, 30*time.Millisecond) // target joins voice target := seedVoiceOwner(t, database, "toctou-target") targetCh := make(chan []byte, 32) targetClient := ws.NewTestClientWithUser(hub, target, 0, targetCh) hub.Register(targetClient) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, targetClient) hub.HandleMessageForTest(targetClient, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(targetCh) + drainChanTimeout(targetCh, 30*time.Millisecond) // Send an E2EE offer from sender to target — should succeed since both // are in the same channel. encKey := validB64("encrypted-room-key-data") iv := validB64("twelve-bytes") hub.HandleMessageForTest(senderClient, e2eeOfferMsg(target.ID, encKey, iv)) - time.Sleep(30 * time.Millisecond) // Target should receive the offer relay. - msgs := drainChan(targetCh) + msgs := drainChanTimeout(targetCh, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "voice_e2ee_offer" { @@ -140,28 +137,25 @@ func TestE2EE_Offer_RejectsNonKeyHolder(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) // user2 (higher ID) joins — should NOT be key holder user2 := seedVoiceOwner(t, database, "kh-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send2) + drainChanTimeout(send2, 30*time.Millisecond) // user2 tries to send an E2EE offer — should be rejected encKey := validB64("encrypted-room-key-data") iv := validB64("twelve-bytes") hub.HandleMessageForTest(c2, e2eeOfferMsg(user1.ID, encKey, iv)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send2) + msgs := drainChanTimeout(send2, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "error" && extractCode(t, m) == "NOT_KEY_HOLDER" { @@ -183,28 +177,25 @@ func TestE2EE_Offer_KeyHolderCanSend(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) // user2 (higher ID) joins user2 := seedVoiceOwner(t, database, "kh-ok-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send2) + drainChanTimeout(send2, 30*time.Millisecond) // user1 (key holder) sends offer to user2 — should succeed encKey := validB64("encrypted-room-key-data") iv := validB64("twelve-bytes") hub.HandleMessageForTest(c1, e2eeOfferMsg(user2.ID, encKey, iv)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send2) + msgs := drainChanTimeout(send2, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "voice_e2ee_offer" { @@ -226,45 +217,40 @@ func TestE2EE_KeyHolderTransfersOnLeave(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) // user2 (higher ID) joins user2 := seedVoiceOwner(t, database, "kht-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) // user3 (highest ID) joins user3 := seedVoiceOwner(t, database, "kht-user3") send3 := make(chan []byte, 32) c3 := ws.NewTestClientWithUser(hub, user3, 0, send3) hub.Register(c3) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c3) hub.HandleMessageForTest(c3, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) drainChan(send2) drainChan(send3) // user1 (key holder) leaves hub.HandleMessageForTest(c1, voiceLeaveMsg()) - time.Sleep(50 * time.Millisecond) - drainChan(send2) + drainChanTimeout(send2, 50*time.Millisecond) drainChan(send3) // Now user2 should be key holder — user2 sends offer to user3 encKey := validB64("new-key") iv := validB64("twelve-bytes") hub.HandleMessageForTest(c2, e2eeOfferMsg(user3.ID, encKey, iv)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send3) + msgs := drainChanTimeout(send3, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "voice_e2ee_offer" { @@ -287,18 +273,16 @@ func TestE2EE_Announce_AcceptsRawBase64(t *testing.T) { sendCh := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, 0, sendCh) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(sendCh) + drainChanTimeout(sendCh, 30*time.Millisecond) // Send announce with raw (no padding) base64 key rawKey := validURLSafeB64Key() hub.HandleMessageForTest(c, e2eeAnnounceMsg(rawKey)) - time.Sleep(30 * time.Millisecond) // Should NOT receive an error - msgs := drainChan(sendCh) + msgs := drainChanTimeout(sendCh, 30*time.Millisecond) for _, m := range msgs { if extractType(t, m) == "error" { t.Errorf("raw base64 should be accepted, got error: %s", extractMessage(t, m)) @@ -316,29 +300,26 @@ func TestE2EE_Offer_AcceptsRawBase64(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) user2 := seedVoiceOwner(t, database, "b64o-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) drainChan(send2) // Send offer with raw (no padding) base64 encKey := validRawB64("encrypted-room-key-data") iv := validRawB64("twelve-bytes") hub.HandleMessageForTest(c1, e2eeOfferMsg(user2.ID, encKey, iv)) - time.Sleep(30 * time.Millisecond) // Should NOT get an error on sender - msgs1 := drainChan(send1) + msgs1 := drainChanTimeout(send1, 30*time.Millisecond) for _, m := range msgs1 { if extractType(t, m) == "error" { t.Errorf("raw base64 in offer should be accepted, got error: %s", extractMessage(t, m)) @@ -370,15 +351,14 @@ func TestE2EE_GetPubKey_ReturnsKeyAfterAnnounce(t *testing.T) { sendCh := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, 0, sendCh) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(sendCh) + drainChanTimeout(sendCh, 30*time.Millisecond) // Announce a public key key := validB64Key() + // handleE2EEAnnounce stores the key synchronously. hub.HandleMessageForTest(c, e2eeAnnounceMsg(key)) - time.Sleep(30 * time.Millisecond) // Retrieve via hub method — the key should be copied under lock got := hub.GetClientE2EEPubKeyForTest(user.ID) @@ -398,12 +378,11 @@ func TestE2EE_VoiceToken_IncludesIsKeyHolder(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) // Check that user1's voice_token has is_key_holder=true - msgs1 := drainChan(send1) + msgs1 := drainChanTimeout(send1, 50*time.Millisecond) foundToken := false for _, m := range msgs1 { if extractType(t, m) == "voice_token" { @@ -423,11 +402,10 @@ func TestE2EE_VoiceToken_IncludesIsKeyHolder(t *testing.T) { send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) - msgs2 := drainChan(send2) + msgs2 := drainChanTimeout(send2, 50*time.Millisecond) foundToken2 := false for _, m := range msgs2 { if extractType(t, m) == "voice_token" { @@ -499,25 +477,21 @@ func TestE2EE_AnnounceSignature_RelayedToPeers(t *testing.T) { send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) drainChan(send2) key := validB64Key() sig := validB64SigStr() hub.HandleMessageForTest(c1, e2eeAnnounceMsgSigned(key, sig)) - time.Sleep(30 * time.Millisecond) - found := false - for _, m := range drainChan(send2) { - if extractType(t, m) != "voice_e2ee_announce" { - continue - } - found = true + m := waitMsgOfType(send2, "voice_e2ee_announce", waitTimeout) + if m == nil { + t.Error("peer should receive the signed announce") + } else { gotSig, _ := extractPayloadField(t, m, "signature").(string) if gotSig != sig { t.Errorf("relayed signature = %q, want %q", gotSig, sig) @@ -527,9 +501,6 @@ func TestE2EE_AnnounceSignature_RelayedToPeers(t *testing.T) { t.Errorf("relayed public_key = %q, want %q", gotKey, key) } } - if !found { - t.Error("peer should receive the signed announce") - } } func TestE2EE_AnnounceSignature_ReplayedToLateJoiner(t *testing.T) { @@ -540,39 +511,31 @@ func TestE2EE_AnnounceSignature_ReplayedToLateJoiner(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) key := validB64Key() sig := validB64SigStr() hub.HandleMessageForTest(c1, e2eeAnnounceMsgSigned(key, sig)) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) // A late joiner must receive the stored announce WITH its signature. user2 := seedVoiceOwner(t, database, "sigrp-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - found := false - for _, m := range drainChan(send2) { - if extractType(t, m) != "voice_e2ee_announce" { - continue - } - found = true + m := waitMsgOfType(send2, "voice_e2ee_announce", waitTimeout) + if m == nil { + t.Error("late joiner should receive the replayed announce") + } else { gotSig, _ := extractPayloadField(t, m, "signature").(string) if gotSig != sig { t.Errorf("replayed signature = %q, want %q", gotSig, sig) } } - if !found { - t.Error("late joiner should receive the replayed announce") - } } func TestE2EE_AnnounceNoSignature_ReplayOmitsField(t *testing.T) { @@ -583,25 +546,19 @@ func TestE2EE_AnnounceNoSignature_ReplayOmitsField(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) hub.HandleMessageForTest(c1, e2eeAnnounceMsg(validB64Key())) - time.Sleep(30 * time.Millisecond) - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) user2 := seedVoiceOwner(t, database, "nosig-user2") send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - for _, m := range drainChan(send2) { - if extractType(t, m) != "voice_e2ee_announce" { - continue - } + if m := waitMsgOfType(send2, "voice_e2ee_announce", waitTimeout); m != nil { if v := extractPayloadField(t, m, "signature"); v != nil { t.Errorf("legacy replay must omit signature, got %v", v) } diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index 7ccc30a2..c158effa 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -183,10 +183,9 @@ func TestVoice_Join_SetsStateInDB(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { @@ -214,21 +213,12 @@ func TestVoice_Join_BroadcastsVoiceState(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) - // Look for a voice_state message in either send or send2. - foundVoiceState := false - allMsgs := append(drainChan(send), drainChan(send2)...) - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_state" { - foundVoiceState = true - break - } - } - if !foundVoiceState { + // The second channel member must receive the voice_state broadcast. + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { t.Error("voice_state broadcast not received after voice_join") } } @@ -242,24 +232,22 @@ func TestVoice_Join_SendsCurrentStatesToJoiner(t *testing.T) { send1 := make(chan []byte, 16) c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) // Drain send1 to clear join broadcast. - drainChan(send1) + drainChanTimeout(send1, 30*time.Millisecond) // user2 joins — should receive voice_state for user1. user2 := seedVoiceOwner(t, database, "carol2") send2 := make(chan []byte, 16) c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) // user2 should have received a voice_state for user1. - msgs2 := drainChan(send2) + msgs2 := drainChanTimeout(send2, 50*time.Millisecond) voiceStateCount := 0 for _, msg := range msgs2 { if extractType(t, msg) == "voice_state" { @@ -277,16 +265,15 @@ func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) badMsg, _ := json.Marshal(map[string]any{ "type": "voice_join", "payload": map[string]any{"channel_id": 0}, }) hub.HandleMessageForTest(c, badMsg) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "error" { @@ -305,12 +292,11 @@ func TestVoice_Join_NoPermission_SendsError(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClient(hub, 9999, send) // no user set → hasChannelPerm returns false hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "error" { @@ -332,13 +318,11 @@ func TestVoice_Leave_ClearsStateInDB(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) hub.HandleMessageForTest(c, voiceLeaveMsg()) - time.Sleep(30 * time.Millisecond) state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { @@ -363,25 +347,19 @@ func TestVoice_Leave_BroadcastsVoiceLeave(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) + // Consume the join's voice_state broadcast so only the leave remains. + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { + t.Fatal("no voice_state broadcast after voice_join") + } drainChan(send) drainChan(send2) hub.HandleMessageForTest(c, voiceLeaveMsg()) - time.Sleep(50 * time.Millisecond) - allMsgs := append(drainChan(send), drainChan(send2)...) - found := false - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_leave" { - found = true - break - } - } - if !found { + if receiveMsgOfType(send2, "voice_leave", waitTimeout) == nil { t.Error("voice_leave broadcast not received after voice_leave message") } } @@ -396,13 +374,11 @@ func TestVoice_Mute_UpdatesStateInDB(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) hub.HandleMessageForTest(c, voiceMuteMsg(true)) - time.Sleep(30 * time.Millisecond) state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { @@ -427,24 +403,19 @@ func TestVoice_Mute_BroadcastsVoiceState(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) + // Consume the join's voice_state broadcast so the next one seen is the mute's. + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { + t.Fatal("no voice_state broadcast after voice_join") + } drainChan(send) drainChan(send2) hub.HandleMessageForTest(c, voiceMuteMsg(true)) - time.Sleep(50 * time.Millisecond) - allMsgs := append(drainChan(send), drainChan(send2)...) - found := false - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_state" { - found = true - break - } - } + found := receiveMsgOfType(send2, "voice_state", waitTimeout) != nil if !found { t.Error("voice_state broadcast not received after voice_mute") } @@ -460,13 +431,11 @@ func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) hub.HandleMessageForTest(c, voiceDeafenMsg(true)) - time.Sleep(30 * time.Millisecond) state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { @@ -491,24 +460,19 @@ func TestVoice_Deafen_BroadcastsVoiceState(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) + // Consume the join's voice_state broadcast so the next one seen is the deafen's. + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { + t.Fatal("no voice_state broadcast after voice_join") + } drainChan(send) drainChan(send2) hub.HandleMessageForTest(c, voiceDeafenMsg(true)) - time.Sleep(50 * time.Millisecond) - allMsgs := append(drainChan(send), drainChan(send2)...) - found := false - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_state" { - found = true - break - } - } + found := receiveMsgOfType(send2, "voice_state", waitTimeout) != nil if !found { t.Error("voice_state broadcast not received after voice_deafen") } @@ -540,17 +504,19 @@ func TestVoice_Camera_UpdatesState(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) - // Join voice channel first. + // Join voice channel first; consume the join's voice_state broadcast so + // the payload check below sees the camera toggle's broadcast. hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { + t.Fatal("no voice_state broadcast after voice_join") + } drainChan(send) drainChan(send2) // Toggle camera on. hub.HandleMessageForTest(c, voiceCameraMsg(true)) - time.Sleep(50 * time.Millisecond) // Verify DB state. state, err := database.GetVoiceState(context.Background(), user.ID) @@ -562,25 +528,11 @@ func TestVoice_Camera_UpdatesState(t *testing.T) { } // Verify voice_state broadcast received by channel member. - allMsgs := append(drainChan(send), drainChan(send2)...) foundVoiceState := false - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_state" { - foundVoiceState = true - - var env struct { - Type string `json:"type"` - Payload struct { - Camera bool `json:"camera"` - } `json:"payload"` - } - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal voice_state: %v", err) - } - if !env.Payload.Camera { - t.Error("voice_state broadcast payload.camera = false, want true") - } - break + if payload := receiveMsgOfType(send2, "voice_state", waitTimeout); payload != nil { + foundVoiceState = true + if cam, _ := payload["camera"].(bool); !cam { + t.Error("voice_state broadcast payload.camera = false, want true") } } if !foundVoiceState { @@ -596,12 +548,11 @@ func TestVoice_Camera_NoPermission(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClient(hub, 7001, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceCameraMsg(true)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "error" { @@ -622,19 +573,17 @@ func TestVoice_Camera_RateLimit(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Send 5 camera toggles rapidly — limit is 2/sec, so some should be rate-limited. for range 5 { hub.HandleMessageForTest(c, voiceCameraMsg(true)) } - time.Sleep(50 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) errCount := 0 for _, m := range msgs { if extractType(t, m) == "error" { @@ -672,17 +621,19 @@ func TestVoice_Screenshare_UpdatesState(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) - // Join voice channel first. + // Join voice channel first; consume the join's voice_state broadcast so + // the payload check below sees the screenshare toggle's broadcast. hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) + if receiveMsgOfType(send2, "voice_state", waitTimeout) == nil { + t.Fatal("no voice_state broadcast after voice_join") + } drainChan(send) drainChan(send2) // Toggle screenshare on. hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) - time.Sleep(50 * time.Millisecond) // Verify DB state. state, err := database.GetVoiceState(context.Background(), user.ID) @@ -694,25 +645,11 @@ func TestVoice_Screenshare_UpdatesState(t *testing.T) { } // Verify voice_state broadcast received. - allMsgs := append(drainChan(send), drainChan(send2)...) foundVoiceState := false - for _, msg := range allMsgs { - if extractType(t, msg) == "voice_state" { - foundVoiceState = true - - var env struct { - Type string `json:"type"` - Payload struct { - Screenshare bool `json:"screenshare"` - } `json:"payload"` - } - if err := json.Unmarshal(msg, &env); err != nil { - t.Fatalf("unmarshal voice_state: %v", err) - } - if !env.Payload.Screenshare { - t.Error("voice_state broadcast payload.screenshare = false, want true") - } - break + if payload := receiveMsgOfType(send2, "voice_state", waitTimeout); payload != nil { + foundVoiceState = true + if ss, _ := payload["screenshare"].(bool); !ss { + t.Error("voice_state broadcast payload.screenshare = false, want true") } } if !foundVoiceState { @@ -728,12 +665,11 @@ func TestVoice_Screenshare_NoPermission(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClient(hub, 7002, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) found := false for _, m := range msgs { if extractType(t, m) == "error" { @@ -754,19 +690,17 @@ func TestVoice_Screenshare_RateLimit(t *testing.T) { send := make(chan []byte, 64) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Send 5 screenshare toggles rapidly — limit is 2/sec. for range 5 { hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) } - time.Sleep(50 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) errCount := 0 for _, m := range msgs { if extractType(t, m) == "error" { @@ -788,17 +722,15 @@ func TestVoice_HandleMessage_VoiceCamera_Dispatched(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). hub.HandleMessageForTest(c, voiceCameraMsg(true)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) for _, m := range msgs { if extractType(t, m) == "error" { var errEnv struct { @@ -823,17 +755,15 @@ func TestVoice_HandleMessage_VoiceScreenshare_Dispatched(t *testing.T) { send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) - time.Sleep(30 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) for _, m := range msgs { if extractType(t, m) == "error" { var errEnv struct { @@ -875,11 +805,10 @@ func TestVoice_Join_ChannelFull(t *testing.T) { send1 := make(chan []byte, 32) c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) hub.Register(c1) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c1) // First user joins — should succeed. hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) // Verify first user is in DB. state1, err := database.GetVoiceState(context.Background(), user1.ID) @@ -891,16 +820,15 @@ func TestVoice_Join_ChannelFull(t *testing.T) { send2 := make(chan []byte, 32) c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) hub.Register(c2) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c2) drainChan(send1) drainChan(send2) // Second user joins — should get CHANNEL_FULL error. hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) - msgs2 := drainChan(send2) + msgs2 := drainChanTimeout(send2, 50*time.Millisecond) foundFull := false for _, msg := range msgs2 { if extractType(t, msg) == "error" { @@ -941,12 +869,11 @@ func TestVoice_Join_SendsVoiceConfig(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) - msgs := drainChan(send) + msgs := drainChanTimeout(send, 50*time.Millisecond) foundConfig := false for _, msg := range msgs { if extractType(t, msg) == "voice_config" { @@ -992,12 +919,11 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, userA, chanA, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) // Join channel A. hub.HandleMessageForTest(c, voiceJoinMsg(chanA)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Verify in channel A via DB. stateA, _ := database.GetVoiceState(context.Background(), userA.ID) @@ -1007,7 +933,6 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { // Join channel B — should leave A first. hub.HandleMessageForTest(c, voiceJoinMsg(chanB)) - time.Sleep(50 * time.Millisecond) // DB state should show channel B. stateB, _ := database.GetVoiceState(context.Background(), userA.ID) @@ -1026,18 +951,16 @@ func TestVoice_Join_SameChannel_IsIdempotent(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) - drainChan(send) + drainChanTimeout(send, 30*time.Millisecond) // Join same channel again. hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(30 * time.Millisecond) // Should receive ALREADY_JOINED error. - msgs := drainChan(send) + msgs := drainChanTimeout(send, 30*time.Millisecond) foundAlreadyJoined := false for _, m := range msgs { if code := extractCode(t, m); code == "ALREADY_JOINED" { @@ -1061,14 +984,12 @@ func TestVoice_Leave_OnDisconnect(t *testing.T) { send := make(chan []byte, 32) c := ws.NewTestClientWithUser(hub, user, chanID, send) hub.Register(c) - time.Sleep(20 * time.Millisecond) + waitRegistered(t, hub, c) hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) - time.Sleep(50 * time.Millisecond) // Simulate disconnect by calling the exported test hook. hub.HandleVoiceLeaveForTest(c) - time.Sleep(30 * time.Millisecond) // DB state should be cleared. state, err := database.GetVoiceState(context.Background(), user.ID) diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index b3e36e36..73138ae5 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" "time" + "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) @@ -46,7 +46,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // Rate limit: voice_join broadcasts a voice_state update to every connected // client, so cap how often a single user can trigger the fan-out. Mirrors the // Limiter.Allow(...) idiom used by the voice control handlers. - ratKey := fmt.Sprintf("voice_join:%d", c.userID) + ratKey := auth.Key("voice_join", c.userID) if h.limiter != nil && !h.limiter.Allow(ratKey, voiceJoinRateLimit, voiceJoinWindow) { c.sendMsg(buildErrorMsg(ErrCodeRateLimited, "too many voice join attempts")) return @@ -169,11 +169,39 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // rollback does not broadcast a spurious voice_leave for an unannounced join. if h.livekit != nil { // Derive publish permissions from role — prevents SFU-level bypass - // when client connects directly via direct_url (BUG-128). - canPublish := h.hasChannelPerm(ctx, c, channelID, permissions.SpeakVoice) + // when client connects directly via direct_url (BUG-128). With a + // PermissionService the three bits come from the per-user cache; the + // bare-hub fallback answers them from one role fetch + one overrides + // fetch via HasChannelPermBatch instead of three hasChannelPerm round + // trips. Both branches fail closed: an unresolved role or override map + // yields no publish grants (admins bypass overrides, so an override + // fetch error cannot demote them). + var canPublish, canVideo, canScreenShare bool canSubscribe := true - canVideo := h.hasChannelPerm(ctx, c, channelID, permissions.UseVideo) - canScreenShare := h.hasChannelPerm(ctx, c, channelID, permissions.ShareScreen) + if h.perms != nil { + // PermissionService answers all three bits from one cached + // role+overrides snapshot (populated by the CONNECT_VOICE gate + // above, so these are cache hits). Same fail-closed posture: an + // unresolved role or override map yields no publish grants. + canPublish = h.perms.HasChannelPerm(ctx, c.userID, channelID, permissions.SpeakVoice) + canVideo = h.perms.HasChannelPerm(ctx, c.userID, channelID, permissions.UseVideo) + canScreenShare = h.perms.HasChannelPerm(ctx, c.userID, channelID, permissions.ShareScreen) + } else if role, roleErr := h.db.GetRoleForUser(ctx, c.userID); roleErr == nil && role != nil { + // Admins bypass overrides, so skip the fetch for them (mirrors + // computeAllowedChannels); HasChannelPermBatch answers true from + // the role bits alone. + var overrides map[int64]db.ChannelOverride + var oErr error + if !permissions.HasAdmin(role.Permissions) { + overrides, oErr = h.db.GetAllChannelPermissionsForRole(ctx, role.ID) + } + if oErr == nil { + po := permOverrides(overrides) + canPublish = h.permChecker.HasChannelPermBatch(role.Permissions, po, channelID, permissions.SpeakVoice) + canVideo = h.permChecker.HasChannelPermBatch(role.Permissions, po, channelID, permissions.UseVideo) + canScreenShare = h.permChecker.HasChannelPermBatch(role.Permissions, po, channelID, permissions.ShareScreen) + } + } token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, state.JoinedAt, canPublish, canSubscribe, canVideo, canScreenShare) if tokenErr != nil { slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) @@ -260,7 +288,7 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo userID := info.UserID channelID := info.VoiceChannelID - ratKey := fmt.Sprintf("voice_token_refresh:%d", userID) + ratKey := auth.Key("voice_token_refresh", userID) if d.Limiter != nil && !d.Limiter.Allow(ratKey, 1, 60*time.Second) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "token refresh rate limited"}} } @@ -283,17 +311,20 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo // Channel-type aware, like the voice_join gate: this mints the same // RoomJoin+CanSubscribe credential, so a role-only check here would keep // re-issuing one for a DM the user is not a participant of. - if !hasChannelAccess(ctx, d.DB, d.Permissions, userID, channelID, permissions.ConnectVoice) { + if !hasChannelAccess(ctx, d.DB, d.Permissions, d.PermSvc, userID, channelID, permissions.ConnectVoice) { return Result{ Error: ClientError{Code: ErrCodeForbidden, Message: "missing CONNECT_VOICE permission"}, LeaveVoice: true, } } - canPublish := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice) + // With a PermissionService these three are cache hits after the gate above + // populated the user's entry — the refresh drops from ~9 DB reads to at + // most one channel-row lookup. + canPublish := hasPerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, channelID, permissions.SpeakVoice) canSubscribe := true - canVideo := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.UseVideo) - canScreenShare := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.ShareScreen) + canVideo := hasPerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, channelID, permissions.UseVideo) + canScreenShare := hasPerm(ctx, d.DB, d.Permissions, d.PermSvc, userID, channelID, permissions.ShareScreen) joinToken := info.VoiceJoinToken var result Result diff --git a/Server/ws/wait_helpers_test.go b/Server/ws/wait_helpers_test.go new file mode 100644 index 00000000..af1dc132 --- /dev/null +++ b/Server/ws/wait_helpers_test.go @@ -0,0 +1,87 @@ +package ws_test + +// wait_helpers_test.go holds condition-based waiting helpers shared by the +// ws_test files. They replace fixed time.Sleep pacing with polling (for state) +// or blocking receives (for channels), making the suite faster and less flaky. +// +// Semantics guide: +// - waitFor / waitRegistered / waitClientCount: wait-for-effect — return as +// soon as the condition holds, fail the test after the timeout. +// - waitMsgOfType: blocking scan of a channel for a message of a given +// envelope type (wait-for-effect); returns nil on timeout. +// +// For bounded-window collection ("all messages that arrive within d", +// including absence-over-a-set assertions) use drainChanTimeout from +// coverage_helpers_test.go — it always waits the full window, matching the +// old sleep+drain behavior. + +import ( + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/ws" +) + +const ( + // waitTimeout bounds all wait-for-effect helpers. Generous on purpose: + // it is only ever paid in full when a test is about to fail. + waitTimeout = 2 * time.Second + // waitPoll is the polling interval for state-based waits. + waitPoll = time.Millisecond +) + +// waitFor polls cond every waitPoll until it returns true or timeout elapses, +// failing the test with msg on timeout. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if cond() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %v waiting for %s", timeout, msg) + } + time.Sleep(waitPoll) + } +} + +// waitRegistered blocks until c is the hub's registered client for its user, +// i.e. the async Register round-trip through the hub loop has completed. +// Because client events are processed in order, waiting on the most recently +// registered client also guarantees all earlier Register calls completed. +func waitRegistered(t *testing.T, hub *ws.Hub, c *ws.Client) { + t.Helper() + waitFor(t, waitTimeout, func() bool { + return hub.GetClient(ws.ClientUserIDForTest(c)) == c + }, "client to be registered with the hub") +} + +// waitClientCount blocks until the hub's client count equals n. +func waitClientCount(t *testing.T, hub *ws.Hub, n int) { + t.Helper() + waitFor(t, waitTimeout, func() bool { + return hub.ClientCount() == n + }, "hub client count to settle") +} + +// waitMsgOfType reads ch until a message whose envelope "type" equals msgType +// arrives, returning the raw message, or nil once timeout expires. +func waitMsgOfType(ch <-chan []byte, msgType string, timeout time.Duration) []byte { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case m := <-ch: + var env struct { + Type string `json:"type"` + } + if json.Unmarshal(m, &env) == nil && env.Type == msgType { + return m + } + case <-timer.C: + return nil + } + } +} diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 353ab00e..2e6439f6 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -79,7 +80,8 @@ func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) { // but closing immediately should cause a read error on the server side. _ = conn.Close(websocket.StatusNormalClosure, "no auth") - // Give the server a moment to react. + // Absence assertion: give the server a bounded window in which a buggy + // registration would land before checking nothing appeared. time.Sleep(50 * time.Millisecond) // Hub should have no clients registered. @@ -355,8 +357,8 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { t.Errorf("second response type = %q, want ready", readyMsg["type"]) } - // Give hub a moment to register the client. - time.Sleep(30 * time.Millisecond) + // Registration happens before the ready frame is written (serve.go), so + // having read ready implies the client is registered. if hub.ClientCount() != 1 { t.Errorf("ClientCount = %d after successful auth, want 1", hub.ClientCount()) } @@ -784,7 +786,9 @@ func TestServeWS_Reconnect_AuthorizedVoiceClientKeepsChannelStream(t *testing.T) t.Fatal("expected replacement client with transferred voice state") } // The topic subscription lands just after the client enters the hub map. - time.Sleep(100 * time.Millisecond) + waitFor(t, waitTimeout, func() bool { + return slices.Contains(hub.PubSubForTest().TopicsForClient(userID), ws.ChannelTopic(chID)) + }, "reconnected client to be resubscribed to the voice channel topic") hub.BroadcastToChannel(chID, []byte(`{"type":"chat_message","payload":{"content":"still-visible"}}`)) @@ -971,8 +975,13 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { conn2, readyMsg := dialAndReadReady(token) defer func() { _ = conn2.Close(websocket.StatusNormalClosure, "") }() - // Wait for replacement to register fully and broadcasts to propagate. - time.Sleep(500 * time.Millisecond) + // The replacement registers before its ready frame is written; wait for + // the hub's client map to show the swap. Broadcast propagation is covered + // by the observer read loop's own timeout below. + waitFor(t, waitTimeout, func() bool { + c := hub.GetClient(userID) + return c != nil && c != originalClient + }, "replacement client to take over in the hub") // Assert 1: replacement client voiceChID == 0 replacementClient := hub.GetClient(userID) @@ -1092,8 +1101,8 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { } } - // Wait for client to be registered and then broadcast a server_restart. - time.Sleep(50 * time.Millisecond) + // Having read ready implies registration completed (serve.go registers + // before writing ready) — broadcast immediately. hub.BroadcastServerRestart("test", 0) // The client should receive the broadcast via writePump. @@ -1211,8 +1220,8 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { connB := connectAndAuth("clientB", tokenB) defer func() { _ = connB.Close(websocket.StatusNormalClosure, "") }() - // Wait for both clients to be registered in the hub. - time.Sleep(50 * time.Millisecond) + // Both clients have read their ready frames, so both are registered + // (serve.go registers before writing ready). // Client B focuses on the channel so it receives channel-scoped broadcasts. focusMsg, _ := json.Marshal(map[string]any{ @@ -1224,7 +1233,9 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { if err := connB.Write(ctxB, websocket.MessageText, focusMsg); err != nil { t.Fatalf("clientB write channel_focus: %v", err) } - time.Sleep(30 * time.Millisecond) + waitFor(t, waitTimeout, func() bool { + return slices.Contains(hub.PubSubForTest().TopicsForClient(userIDB), ws.ChannelTopic(chID)) + }, "clientB channel_focus subscription to land") // Client A sends a chat message. chatSend, _ := json.Marshal(map[string]any{ @@ -1331,8 +1342,8 @@ func TestIntegration_SequenceNumbers(t *testing.T) { } } - // Wait for registration. - time.Sleep(50 * time.Millisecond) + // Having read ready implies registration completed (serve.go registers + // before writing ready). // Trigger two broadcasts. hub.BroadcastServerRestart("test-seq-1", 10) diff --git a/docs/credential-storage.md b/docs/credential-storage.md new file mode 100644 index 00000000..7cb3341c --- /dev/null +++ b/docs/credential-storage.md @@ -0,0 +1,186 @@ +# Credential storage + +The desktop client persists two secrets per server, both in the OS credential +store under the service name `com.owncord.client`: + +| Secret | Account name | Contents | +| --- | --- | --- | +| Login credential | `{host}` | JSON `{"username","token","password"}` | +| Voice-E2EE identity private key | `identity:{host}` | base64 JWK (P-256 private key) | + +The identity key is the long-term key peers pin under trust-on-first-use. Its +public half is published to the server (`users.identity_public_key`) and its +private half signs the ephemeral-key announce in a voice session. **If the key +is not the same one across a reconnect, the published key is not the key that +signed, and peers correctly reject the announce** — voice E2EE key exchange then +times out and the client log shows: + +``` +peer announce signature invalid - rejecting (MITM?) +``` + +That rejection is the fail-closed behaviour working as intended. The bug in that +scenario is always on the storage side, never the verification side. + +## Root cause of the 2026-07 identity-key regression + +The client depended on `keyring = "3"` with no feature list. + +**The `keyring` crate declares no `default` feature.** Each platform arm in its +`lib.rs` selects a backend only when that platform's feature is enabled, and +otherwise falls through to the mock store: + +```rust +#[cfg(all(target_os = "windows", not(feature = "windows-native")))] +pub use mock as default; +#[cfg(all(target_os = "macos", not(feature = "apple-native")))] +pub use mock as default; +// ...and the equivalent Linux arm for the secret-service / keyutils features +``` + +So the shipped client used the **mock credential store on Windows, macOS and +Linux alike**. The mock is documented as "platform-independent, provides no +persistence… there is no persistence other than in the entry itself", and its +builder returns a fresh `MockCredential` for every `Entry::new`. Because each +command built its own `Entry`, the sequence was: + +``` +save_identity_key -> Entry::new(..) -> set_password -> Ok(()) // Entry dropped here +load_identity_key -> Entry::new(..) -> get_password -> NoEntry // brand-new empty cell +``` + +This reproduces every reported symptom exactly: + +- The save returns `Ok`, so no "Failed to save identity key" line is ever logged. +- The very next read **in the same process** returns nothing. +- `NoEntry` is mapped to `Ok(None)`, so the read logs nothing either. +- Windows Credential Manager and `cmdkey /list` show no `com.owncord.client` + entries **on any machine**, including ones where the client otherwise behaves + — because nothing was ever written to Credential Manager. + +Whether a given user visibly regenerates their key therefore depends on process +lifetime and on the publish step being idempotent, not on their machine. It is +not a Windows policy fault, a roaming-profile fault, or a target-name fault. + +### The fix + +`src-tauri/Cargo.toml` now names the backends explicitly: + +```toml +keyring = { version = "3", default-features = false, features = [ + "windows-native", # Windows Credential Manager + "apple-native", # macOS Keychain + "sync-secret-service", # Linux Secret Service (GNOME Keyring / KWallet) + "crypto-rust", # pure-Rust session crypto for Secret Service +] } +``` + +`sync-secret-service` links libdbus, so Linux builds need `libdbus-1-dev`. + +Two guards keep this from regressing silently: + +- `secret_store::tests::compiled_keyring_backend_is_persistent` fails the build + if the compiled backend is not disk-persistent. It inspects the backend, so it + needs no live keychain and runs in CI. +- Every write is read back before `save_*` returns (see below). + +## Verifying the credential store on a machine + +### From the client + +The client logs its compiled backend at startup, to the rotating log file in the +OS app-log dir: + +``` +credential store: OS keyring, persists until deleted (on disk) +``` + +Anything else is an error line naming the problem. + +For a live end-to-end check there is a `probe_credential_store` command. It +writes, reads back and deletes a throwaway entry and reports which backend +served it, touching no real credential: + +```js +await invoke("probe_credential_store") +// { ok: true, backend: "keyring", error: null } +``` + +### From Windows directly + +Use `cmdkey`, **not** the Credential Manager control panel — the control panel +filters out generic credentials written by applications, so it shows nothing +even on a perfectly healthy machine. + +`keyring` composes the Windows target name as `{account}.{service}`, so the +identity key for `test.example` is stored as: + +``` +identity:test.example.com.owncord.client +``` + +Note the order: account first, service second. To check for a real entry: + +``` +cmdkey /list | findstr /i owncord +``` + +A hand-rolled `cmdkey /generic:... ` round-trip is a **weak** test and can pass +while the client still fails, for two reasons: + +- `cmdkey /generic:` writes with `CRED_PERSIST_LOCAL_MACHINE`, whereas `keyring` + hardcodes `CRED_PERSIST_ENTERPRISE` (`windows.rs`, `save_credential`). Only + the latter is subject to roaming-credential policy. +- It exercises a target name the client never uses unless you spell it in the + `{account}.{service}` order above. + +If you do want the exact repro: + +``` +cmdkey /generic:identity:test.example.com.owncord.client /user:x /pass:y +cmdkey /list | findstr /i owncord +``` + +## Environment causes that remain possible + +These were not the cause of the 2026-07 regression, but they can genuinely stop +Windows persisting credentials, and the client now detects and reports them +instead of silently regenerating keys. + +| Cause | Check | Fix | +| --- | --- | --- | +| Credential Manager service stopped | `sc query VaultSvc` | `sc config VaultSvc start= auto && sc start VaultSvc` | +| "Network access: Do not allow storage of passwords and credentials for network authentication" | `reg query HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v DisableDomainCreds` | Set the policy to *Disabled* (`secpol.msc` → Local Policies → Security Options), i.e. `DisableDomainCreds = 0`. Note this blocks *domain* credentials and makes writes fail with `ERROR_NO_SUCH_LOGON_SESSION`, which the client surfaces as an error rather than silently. | +| No roaming profile, with `CRED_PERSIST_ENTERPRISE` | — | Documented Windows behaviour: the credential simply persists locally instead of roaming. Harmless. | +| App running as a different user than the vault being inspected | `whoami` in the app's context vs. the one running `cmdkey` | Credentials are per-user; compare like for like. | + +Blob size is not a plausible cause: `CRED_MAX_CREDENTIAL_BLOB_SIZE` is 2560 +bytes and `keyring` stores the secret as UTF-16, so the ceiling is ~1280 +characters. The identity blob is a ~256-character base64 JWK (~512 bytes), and +an oversized secret would be rejected up front with a `TooLong` error, not +silently dropped. + +## Write verification and the fallback store + +`secret_store::set` reads every write back and compares it before reporting +success. A store that accepts a write and does not return it is the one failure +a `Result` cannot express, and it is exactly what caused this incident. + +When that check fails **on Windows**, the secret is encrypted with DPAPI +(`CryptProtectData`, user-scoped, `CRYPTPROTECT_UI_FORBIDDEN`) and parked in +`credential_fallback.json` in the app data dir. The account name is mixed into +the DPAPI entropy, so a blob cannot be moved between entries and still decrypt. +The fallback: + +- engages **only** after a write has been proven not to round-trip — never as + the default; +- is cleared automatically as soon as the OS credential store works again, so a + repaired machine returns to the real store with no migration step; +- exists on Windows only. On macOS and Linux a failing Keychain / Secret Service + is reported as an error instead, because writing a login password or an + identity private key to a plaintext file there would be a worse outcome than + not persisting it. + +None of this weakens the fail-closed E2EE posture: a peer whose announce +signature does not verify is still rejected. The fallback only affects whether +*our own* key survives a restart. diff --git a/docs/deployment.md b/docs/deployment.md index a9e6474e..8a60186d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -111,7 +111,24 @@ When `chatserver.exe` starts for the first time: 3. **TLS certificate** -- A self-signed certificate is generated at `data/cert.pem` / `data/key.pem` 4. **Database migration** -- SQLite database is created and all migrations run 5. **Status reset** -- All user statuses are set to `offline`, stale voice states are cleared -6. **Admin setup page** -- Navigate to `https://localhost:8443/admin` to create the Owner account +6. **Setup wizard** -- Navigate to `https://localhost:8443/admin` to run the first-time setup wizard + +The setup wizard creates the Owner account and walks through the basics (server +name, port, TLS mode, upload limit, voice, registration and welcome +message). Choices are saved for you: live settings go to the database, and +startup settings are written into `config.yaml` — comments and any hand edits +in the file are preserved. The wizard also persists the generated LiveKit +credentials so voice keeps working across restarts. If the port or TLS mode +changed, the server restarts itself once and the wizard shows the new address. +"Skip" runs the legacy minimal flow: just the Owner account, everything else +on defaults. + +Voice works out of the box: with `voice.auto_download_livekit` enabled (the +default in a freshly generated `config.yaml`, and a toggle in the wizard), the +server downloads a pinned `livekit-server` release from the official LiveKit +GitHub releases in the background — verified against the release checksum +file — into `data/livekit/` and manages the process itself. Operators who run +their own LiveKit can turn the toggle off or set `voice.livekit_binary`. The server listens on `https://0.0.0.0:8443` by default. See [Server Configuration](server-configuration.md) for all options. diff --git a/docs/livekit-setup.md b/docs/livekit-setup.md index 10446235..b742b923 100644 --- a/docs/livekit-setup.md +++ b/docs/livekit-setup.md @@ -57,13 +57,24 @@ When running OwnCord via `docker compose`, LiveKit runs as a separate container ### 1. Get the LiveKit Binary -Download `livekit-server` for your platform from one of: +**You usually don't have to do anything.** With `voice.auto_download_livekit` +enabled (the default in a freshly generated `config.yaml`, and offered as a +toggle in the first-run setup wizard), OwnCord downloads a pinned +`livekit-server` release from the official LiveKit GitHub releases in the +background at startup, verifies it against the release's `checksums.txt`, +stores it in `data/livekit/`, and manages it as the companion process. Pin a +different release with `voice.livekit_version`. + +To provide the binary yourself instead, download `livekit-server` for your +platform from one of: - **GitHub releases**: - - Grab the `livekit-server_*_windows_amd64.zip` asset + - Grab the `livekit_*_windows_amd64.zip` asset - **LiveKit website**: (Docs > Self Hosting) -Extract the binary somewhere permanent (e.g. `C:\livekit\livekit-server.exe`). +Extract the binary somewhere permanent (e.g. `C:\livekit\livekit-server.exe`) +and set `voice.livekit_binary` to that path — a configured path always wins +over auto-download. --- @@ -85,7 +96,9 @@ voice: | `livekit_api_key` | Shared API key between OwnCord and LiveKit | `"devkey"` | | `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` | | `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` | -| `livekit_binary` | Path to `livekit-server` binary. Empty = assume externally managed | `""` (disabled) | +| `livekit_binary` | Path to `livekit-server` binary. Empty + auto-download off = assume externally managed | `""` | +| `auto_download_livekit` | Download and manage a pinned `livekit-server` release automatically when `livekit_binary` is empty | `true` in generated config | +| `livekit_version` | Override the pinned auto-download release (e.g. `"1.13.5"`) | `""` (built-in pin) | | `node_ip` | Public IP for WebRTC ICE candidates (remote users behind NAT) | `""` (auto-detect) | | `advertise_internal_ip` | Also advertise LAN IPs — enable on dual-homed servers (LAN + public IP) so local clients can connect | `false` | | `quality` | Default voice quality preset | `"medium"` | @@ -118,7 +131,7 @@ When `livekit_binary` is set, OwnCord manages LiveKit as a companion process: 4. **Health checks**: `GET http://localhost:7880/` verifies LiveKit is responding 5. **Graceful shutdown**: Stops the process when OwnCord shuts down (5s timeout before kill) -If `livekit_binary` is empty, OwnCord assumes LiveKit is managed externally (e.g. Docker, systemd, or manual start). +If `livekit_binary` is empty and `auto_download_livekit` is off, OwnCord assumes LiveKit is managed externally (e.g. Docker, systemd, or manual start). --- diff --git a/docs/quick-start.md b/docs/quick-start.md index 4862e263..e10e8a8e 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -31,7 +31,9 @@ Get OwnCord running with the fewest possible steps. - Windows: `chatserver.exe` - Linux: `./chatserver` 3. Open `https://localhost:8443/admin`. -4. Create the Owner account. +4. Complete the setup wizard: it creates the Owner account and configures the + basics (server name, port, security, uploads, voice). Your choices are + written to `config.yaml` automatically — no manual editing needed. 5. Create invite codes and share them. ## Option B: Docker (Linux server) @@ -44,7 +46,7 @@ cp livekit.yaml.example livekit.yaml docker compose up -d ``` -Then open `https://localhost:8443/admin` and create the Owner account. +Then open `https://localhost:8443/admin` and complete the setup wizard. Full Docker details: [Deployment Guide](deployment.md#docker-linux). diff --git a/docs/server-configuration.md b/docs/server-configuration.md index faeff370..ed5717d6 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -12,6 +12,18 @@ Configuration is loaded in three layers (later layers override earlier ones): 2. **YAML file** (`config.yaml`) 3. **Environment variables** (prefix: `OWNCORD_`) +### First-run setup wizard + +The admin panel's first-time setup wizard (shown at `/admin` until the Owner +account exists) writes a subset of these keys into `config.yaml` for you: +`server.port`, `server.name`, `tls.mode`, `tls.domain`, `upload.max_size_mb`, +`voice.quality` and `voice.auto_download_livekit`. It also persists the auto-generated +`voice.livekit_api_key` / `voice.livekit_api_secret` (only when the file has +none) so voice tokens survive restarts. The wizard patches the file in place — +comments and hand-edited values it doesn't manage are preserved — and restarts +the server automatically when a startup-only value changed. Note that +`OWNCORD_*` environment variables still override anything the wizard writes. + ## Config Key Reference ### Server (`server`) @@ -55,7 +67,9 @@ Configuration is loaded in three layers (later layers override earlier ones): | `voice.livekit_api_key` | string | *(random per run)* | LiveKit API key. Set a stable value for persistent voice tokens. | | `voice.livekit_api_secret` | string | *(random per run)* | LiveKit API secret (min 32 chars). Set a stable value for persistent tokens. | | `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL | -| `voice.livekit_binary` | string | `""` | Path to `livekit-server` binary; empty = don't auto-start | +| `voice.livekit_binary` | string | `""` | Path to an existing `livekit-server` binary; set to skip auto-download and run your own build | +| `voice.auto_download_livekit` | bool | `false` (compiled) / `true` in the generated config | When no `livekit_binary` is set, download a pinned `livekit-server` release from the official LiveKit GitHub releases (verified against the release `checksums.txt`) into `data/livekit/` and run it automatically | +| `voice.livekit_version` | string | `""` | Override the pinned `livekit-server` version used by auto-download (e.g. `"1.13.5"`); empty = built-in pin | | `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. | | `voice.advertise_internal_ip` | bool | `false` | Also advertise internal (LAN) IPs as ICE candidates. Enable when the server is reachable via both a LAN IP and a public IP so local-network clients can connect to voice. | | `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` |