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); });