From f03c0ef96094dd1dd0d1bf4bb96296ec05aed41d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 19 Jul 2026 22:38:02 -0400 Subject: [PATCH] test: cover cryptoDiagLog + closedLobbyCategories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node runs each test file in its own process, so the console patch can't leak). - cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error / object / circular-arg serialization (String() fallback never throws), the 200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics, install idempotency, and buildCryptoDiagReport's client metadata + LOCKED PII-safe key set (no field can silently leak) + no-client/throwing-getter fallbacks. Silences console pass-through so the ring-buffer test stays quiet. - closedLobbyCategories.test.ts: mirrors closedNavCategories — id join, hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence, per-user key namespacing. Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry; shipped as element-call:lotus e36aef8a). Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok. Co-Authored-By: Claude Opus 4.8 --- LOTUS_TODO.md | 2 +- src/app/state/closedLobbyCategories.test.ts | 102 +++++++++++ src/app/utils/cryptoDiagLog.test.ts | 180 ++++++++++++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/app/state/closedLobbyCategories.test.ts create mode 100644 src/app/utils/cryptoDiagLog.test.ts diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 9feca3eb7..d063d8eed 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -212,7 +212,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab **NOT done — needs a real device / product decisions (open):** - [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E. -- [ ] **Element Call fork in-call mobile UI** — the EC iframe (video grid, EC control bar, spotlight) is `LotusGuild/element-call` at `/root/code/element-call` (we own it); the cinny-side audit couldn't reach it. The actual on-phone call experience is unaudited. +- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone). - [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign). - [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed). - [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to. diff --git a/src/app/state/closedLobbyCategories.test.ts b/src/app/state/closedLobbyCategories.test.ts new file mode 100644 index 000000000..9ce0aa106 --- /dev/null +++ b/src/app/state/closedLobbyCategories.test.ts @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStore } from 'jotai'; +import { enableMapSet } from 'immer'; +import { makeClosedLobbyCategoriesAtom, makeLobbyCategoryId } from './closedLobbyCategories'; + +// makeClosedLobbyCategoriesAtom(userId) builds a Set atom whose reducer uses +// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage +// key `closedLobbyCategories`. The reducers produce over a Set, so enable +// immer's Map/Set plugin (the app does this once at startup). +// makeLobbyCategoryId joins args with '|'. (Mirrors closedNavCategories.test.ts.) +enableMapSet(); + +type Store = Record; +const installLocalStorage = (): Store => { + const data: Store = {}; + const ls = { + getItem: (k: string) => (k in data ? data[k] : null), + setItem: (k: string, v: string) => { + data[k] = String(v); + }, + removeItem: (k: string) => { + delete data[k]; + }, + }; + (globalThis as { localStorage?: unknown }).localStorage = ls; + (globalThis as { window?: unknown }).window = { + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + return data; +}; + +test('makeLobbyCategoryId joins args with "|"', () => { + assert.equal(makeLobbyCategoryId('space', 'catA'), 'space|catA'); + assert.equal(makeLobbyCategoryId('only'), 'only'); + assert.equal(makeLobbyCategoryId(), ''); +}); + +test('starts empty when nothing is stored', () => { + installLocalStorage(); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + assert.equal(store.get(lobbyAtom).size, 0); +}); + +test('hydrates the Set from the per-user localStorage key', () => { + const data = installLocalStorage(); + data['closedLobbyCategories@u:server'] = JSON.stringify(['x', 'y']); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + assert.deepEqual(Array.from(store.get(lobbyAtom)).sort(), ['x', 'y']); +}); + +test('PUT adds a category and DELETE removes it', () => { + installLocalStorage(); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + + store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' }); + assert.deepEqual(Array.from(store.get(lobbyAtom)), ['cat1']); + + store.set(lobbyAtom, { type: 'DELETE', categoryId: 'cat1' }); + assert.equal(store.get(lobbyAtom).has('cat1'), false); +}); + +test('PUT of an existing category is idempotent', () => { + installLocalStorage(); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' }); + store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' }); + assert.equal(store.get(lobbyAtom).size, 1); +}); + +test('DELETE of an absent category is a no-op', () => { + installLocalStorage(); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + store.set(lobbyAtom, { type: 'DELETE', categoryId: 'missing' }); + assert.equal(store.get(lobbyAtom).size, 0); +}); + +test('writes persist to localStorage as an array', () => { + const data = installLocalStorage(); + const store = createStore(); + const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server'); + store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' }); + assert.deepEqual(JSON.parse(data['closedLobbyCategories@u:server']), ['cat1']); +}); + +test('the storage key is namespaced per user', () => { + const data = installLocalStorage(); + const store = createStore(); + const aAtom = makeClosedLobbyCategoriesAtom('@a:server'); + const bAtom = makeClosedLobbyCategoriesAtom('@b:server'); + + store.set(aAtom, { type: 'PUT', categoryId: 'only-a' }); + assert.deepEqual(JSON.parse(data['closedLobbyCategories@a:server']), ['only-a']); + assert.equal(data['closedLobbyCategories@b:server'], undefined); + assert.equal(store.get(bAtom).size, 0); +}); diff --git a/src/app/utils/cryptoDiagLog.test.ts b/src/app/utils/cryptoDiagLog.test.ts new file mode 100644 index 000000000..2350d73e3 --- /dev/null +++ b/src/app/utils/cryptoDiagLog.test.ts @@ -0,0 +1,180 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { MatrixClient } from 'matrix-js-sdk'; +import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog'; + +// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that +// ALWAYS pass through to the original. Silence the originals here (before install +// captures them) so the ring-buffer test doesn't spam hundreds of lines into the +// test output — the capture logic itself still runs. The module's `entries` buffer +// is module-global and accumulates across tests in this file, so assertions use +// deltas or the absolute cap rather than assuming an empty buffer. +console.warn = () => undefined; +console.error = () => undefined; +installCryptoDiagLog(); + +const mockClient = (partial: Partial>): MatrixClient => + partial as unknown as MatrixClient; + +test('captures a KE-signature line and ignores non-matching output', () => { + const before = getCryptoDiagEntries().length; + console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists'); + console.warn('just a normal warning with nothing to capture'); + const after = getCryptoDiagEntries(); + assert.equal(after.length, before + 1, 'only the matching line is captured'); + const last = after[after.length - 1]; + assert.equal(last.ke, 'KE-1'); + assert.equal(last.signature, 'already exists'); + assert.equal(last.level, 'error'); + assert.match(last.message, /already exists/); + assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC +}); + +test('matches the first (tightest) signature when several apply', () => { + const before = getCryptoDiagEntries().length; + // Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3); + // find() returns the first, so the tightest/most-specific label is recorded. + console.error('MissingKey: the session key already exists somehow'); + const e = getCryptoDiagEntries(); + assert.equal(e.length, before + 1); + assert.equal(e[e.length - 1].ke, 'KE-1'); + assert.equal(e[e.length - 1].signature, 'already exists'); +}); + +test('serializes Error and object args into the captured message', () => { + const b1 = getCryptoDiagEntries().length; + console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted')); + const afterErr = getCryptoDiagEntries(); + assert.equal(afterErr.length, b1 + 1); + assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/); + assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2'); + + const b2 = getCryptoDiagEntries().length; + console.warn('missing key at index', { index: 7 }); + const afterObj = getCryptoDiagEntries(); + assert.equal(afterObj.length, b2 + 1); + assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/); + assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2'); +}); + +test('ring-buffers to at most 200 entries, evicting the oldest', () => { + // Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i). + for (let i = 0; i < 250; i += 1) { + console.warn(`delayed event tag=${i};`); + } + const e = getCryptoDiagEntries(); + assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES'); + assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained'); + assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted'); +}); + +test('getCryptoDiagEntries returns a copy, not the live buffer', () => { + const a = getCryptoDiagEntries(); + const b = getCryptoDiagEntries(); + assert.notEqual(a, b, 'each call returns a fresh array'); + const len = a.length; + a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' }); + assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer'); +}); + +// NOTE: these run after the ring-buffer test has filled the buffer to its cap, +// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST +// entry (always the line just pushed) rather than a length delta. +test('captures the KE-3 and KE-4 signatures', () => { + console.error('DecryptionError: unable to decrypt event'); + const e1 = getCryptoDiagEntries(); + assert.equal(e1[e1.length - 1].ke, 'KE-3'); + assert.equal(e1[e1.length - 1].signature, 'DecryptionError'); + + // underscore, not a space, so it matches the tighter 'update_delayed_event' + // row (index 5) rather than the looser 'delayed event' row. + console.warn('msc4157.update_delayed_event timed out'); + const e2 = getCryptoDiagEntries(); + assert.equal(e2[e2.length - 1].ke, 'KE-4'); + assert.equal(e2[e2.length - 1].signature, 'update_delayed_event'); +}); + +test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => { + const circular: Record = {}; + circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String() + // Pair with a matching string so the line is captured; the object exercises + // the catch branch. This must not throw. + console.error('DecryptionError from', circular); + const e = getCryptoDiagEntries(); + assert.equal(e[e.length - 1].ke, 'KE-3'); + assert.match(e[e.length - 1].message, /\[object Object\]/); +}); + +test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => { + const wrappedWarn = console.warn; + installCryptoDiagLog(); // guarded no-op + // Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled. + assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped'); + console.error('DecryptionError single-capture check'); + const e = getCryptoDiagEntries(); + assert.match(e[e.length - 1].message, /single-capture check/, 'still captured'); +}); + +test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => { + const mx = mockClient({ + getDeviceId: () => 'DEVICE123', + getUserId: () => '@alice:example.org', + getSyncState: () => 'SYNCING', + getCrypto: () => ({}), + getSdkVersion: () => '99.9.9', + }); + const report = JSON.parse(buildCryptoDiagReport(mx)); + + assert.equal(report.kind, 'lotus-crypto-diag'); + assert.equal(report.deviceId, 'DEVICE123'); + assert.equal(report.userId, '@alice:example.org'); + assert.equal(report.syncState, 'SYNCING'); + assert.equal(report.cryptoReady, true); + assert.equal(report.sdkVersion, '99.9.9'); + assert.equal(report.maxEntries, 200); + assert.equal(report.entryCount, getCryptoDiagEntries().length); + + const sum = Object.values(report.countsByKe).reduce((a, b) => a + b, 0); + assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount'); + + // Locked field set: nothing beyond these documented keys ever leaks. + assert.deepEqual(Object.keys(report).sort(), [ + 'countsByKe', + 'cryptoReady', + 'deviceId', + 'entries', + 'entryCount', + 'generatedAt', + 'kind', + 'maxEntries', + 'sdkVersion', + 'syncState', + 'userId', + ]); +}); + +test('buildCryptoDiagReport degrades gracefully with no client', () => { + const report = JSON.parse(buildCryptoDiagReport()); + assert.equal(report.deviceId, null); + assert.equal(report.userId, null); + assert.equal(report.syncState, null); + assert.equal(report.cryptoReady, false); + // sdkVersion falls back to the declared package.json pin — a version string. + assert.equal(typeof report.sdkVersion, 'string'); + assert.ok(report.sdkVersion.length > 0); +}); + +test('sdkVersion falls back to the declared pin when the client getter throws', () => { + const mx = mockClient({ + getDeviceId: () => null, + getUserId: () => null, + getSyncState: () => null, + getCrypto: () => undefined, + getSdkVersion: () => { + throw new Error('not ready'); + }, + }); + const report = JSON.parse(buildCryptoDiagReport(mx)); + assert.equal(typeof report.sdkVersion, 'string'); + assert.ok(report.sdkVersion.length > 0); +});