Files
cinny/src/app/utils/searchCache.test.ts
T
jaredandClaude Opus 5 908e735933
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
fix(security): logout's search-index wipe coordinates across tabs
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while
another tab still held the DB, so decrypted rows could survive logout.
It now broadcasts lotus-logout first; every tab closes its handle and
refuses to reopen, then the delete proceeds. A boot with no session
re-runs the wipe once in case a race was still lost. Unit-tested.

Fixes #45

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00

233 lines
8.6 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
computeCoverage,
evictCount,
mergeSearchResults,
putRows,
queryRoom,
getCoverage,
saveRoomIndex,
clearRoom,
clearAll,
deleteRow,
deleteSearchCacheDatabase,
shouldRunBootCleanup,
SearchCacheRow,
} from './searchCache';
// --- Pure helpers: mergeSearchResults ---------------------------------------
type Item = { event: { event_id: string; origin_server_ts?: number } };
const item = (eventId: string, ts?: number): Item => ({
event: { event_id: eventId, origin_server_ts: ts },
});
test('mergeSearchResults: sorts by origin_server_ts descending', () => {
const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$b', '$c', '$a'],
);
});
test('mergeSearchResults: dedupes by event_id with in-memory winning', () => {
const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }];
const cached = [
{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' },
{ event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' },
];
const out = mergeSearchResults(memory, cached);
assert.equal(out.length, 2);
const dup = out.find((i) => i.event.event_id === '$dup');
assert.equal(dup?.tag, 'memory');
});
test('mergeSearchResults: cached-only hits are included', () => {
const out = mergeSearchResults<Item>([], [item('$c1', 1), item('$c2', 2)]);
assert.equal(out.length, 2);
});
test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$withTs', '$noTs'],
);
});
// --- Pure helpers: computeCoverage ------------------------------------------
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
test('evictCount: 0 when under or at the cap, else the excess', () => {
assert.equal(evictCount(0, 100), 0);
assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted
assert.equal(evictCount(101, 100), 1);
assert.equal(evictCount(250, 100), 150);
});
test('evictCount: uses the default per-room cap (5000)', () => {
assert.equal(evictCount(5000), 0);
assert.equal(evictCount(5001), 1);
});
test('computeCoverage: derives oldest/newest from rows', () => {
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
});
test('computeCoverage: widens the window against previous coverage', () => {
const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 };
const cov = computeCoverage('!r', [row(15), row(40)], 4, prev);
assert.equal(cov.oldestTs, 5); // previous oldest kept
assert.equal(cov.newestTs, 40); // batch newest wins
assert.equal(cov.count, 4); // authoritative count from caller
});
test('computeCoverage: empty rows with no previous yields zeroed window', () => {
const cov = computeCoverage('!r', [], 0);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 });
});
// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) ---
const hasIdb = typeof indexedDB !== 'undefined';
test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
await clearAll();
const rows: SearchCacheRow[] = [
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' },
{
roomId: '!r1',
eventId: '$2',
ts: 200,
sender: '@b',
body: 'goodbye',
formattedBody: '<b>x</b>',
},
{ roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' },
];
await putRows(rows);
const r1 = await queryRoom('!r1');
assert.equal(r1.length, 2);
assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']);
await saveRoomIndex(
'!r1',
rows.filter((x) => x.roomId === '!r1'),
);
const cov = await getCoverage('!r1');
assert.equal(cov?.count, 2);
assert.equal(cov?.oldestTs, 100);
assert.equal(cov?.newestTs, 200);
await clearRoom('!r1');
assert.equal((await queryRoom('!r1')).length, 0);
assert.equal((await queryRoom('!r2')).length, 1);
// clearAll() (not deleteSearchCacheDatabase()) so later tests in this file
// can still reopen the DB — deleteSearchCacheDatabase() now permanently
// closes this module's handle (Gitea #45), which in the real app is fine
// because logout always reloads the page right after.
await clearAll();
});
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
await clearAll();
const rows: SearchCacheRow[] = [
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
];
await putRows(rows);
await deleteRow('!r1', '$1');
const r1 = await queryRoom('!r1');
assert.deepEqual(
r1.map((x) => x.eventId),
['$2'],
);
// A same-eventId row in a different room is untouched (composite key).
assert.equal((await queryRoom('!r2')).length, 1);
// Deleting a row that doesn't exist is a silent no-op.
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
await clearAll();
});
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
// In this environment IndexedDB is absent; every call must degrade to a
// cache-miss rather than throwing.
await assert.doesNotReject(
putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]),
);
assert.deepEqual(await queryRoom('!r'), []);
assert.equal(await getCoverage('!r'), null);
await assert.doesNotReject(saveRoomIndex('!r', []));
await assert.doesNotReject(clearRoom('!r'));
await assert.doesNotReject(deleteRow('!r', '$1'));
await assert.doesNotReject(clearAll());
await assert.doesNotReject(deleteSearchCacheDatabase());
});
// --- Gitea #45: boot-time completion wipe decision --------------------------
test('shouldRunBootCleanup: only when signed out and not already run this boot', () => {
assert.equal(shouldRunBootCleanup(false, false), true); // no session, first time
assert.equal(shouldRunBootCleanup(true, false), false); // signed in — never wipe a live cache
assert.equal(shouldRunBootCleanup(false, true), false); // already ran this boot
assert.equal(shouldRunBootCleanup(true, true), false);
});
// --- Gitea #45: closed-flag gating ------------------------------------------
//
// A minimal fake `indexedDB` that only supports what `deleteSearchCacheDatabase()`
// and the `openDb()` open path touch, so this test doesn't need a full
// IDBTransaction mock: it only needs to prove that once
// `deleteSearchCacheDatabase()` has run, nothing calls `indexedDB.open()`
// again in this module instance — the actual guarantee the fix provides.
//
// Note: `closed` is process-wide module state, and other tests above already
// call `deleteSearchCacheDatabase()` (harmlessly, since `indexedDB` was
// `undefined` when they ran), so this module may already be "closed" by the
// time this test runs. That's fine — the property under test doesn't depend
// on the prior state: after (another) `deleteSearchCacheDatabase()` call,
// `indexedDB.open()` must never fire again, however many callers still try.
test('closed-flag gating: deleteSearchCacheDatabase() stops the DB from reopening', async () => {
let openCalls = 0;
const previousIdb = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = {
open: () => {
openCalls += 1;
const req: Record<string, unknown> = { result: {} };
// openDb() assigns onupgradeneeded/onsuccess/onerror/onblocked
// synchronously after calling open(); fire success on the next tick so
// those handlers are already attached.
setTimeout(() => (req.onsuccess as (() => void) | undefined)?.(), 0);
return req;
},
deleteDatabase: () => {
const req: Record<string, unknown> = {};
setTimeout(() => (req.onsuccess as (() => void) | undefined)?.(), 0);
return req;
},
};
try {
await deleteSearchCacheDatabase();
openCalls = 0; // isolate: only count opens attempted AFTER closing
// Every subsequent read must short-circuit to a cache-miss without ever
// calling indexedDB.open() again.
assert.deepEqual(await queryRoom('!gate'), []);
await putRows([{ roomId: '!gate', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]);
assert.equal(openCalls, 0);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previousIdb;
}
});