fix(security): logout's search-index wipe coordinates across tabs
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
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
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
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
clearAll,
|
||||
deleteRow,
|
||||
deleteSearchCacheDatabase,
|
||||
shouldRunBootCleanup,
|
||||
SearchCacheRow,
|
||||
} from './searchCache';
|
||||
|
||||
@@ -126,7 +127,11 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
||||
assert.equal((await queryRoom('!r1')).length, 0);
|
||||
assert.equal((await queryRoom('!r2')).length, 1);
|
||||
|
||||
await deleteSearchCacheDatabase();
|
||||
// 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 () => {
|
||||
@@ -151,7 +156,7 @@ test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasI
|
||||
// Deleting a row that doesn't exist is a silent no-op.
|
||||
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
|
||||
|
||||
await deleteSearchCacheDatabase();
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
||||
@@ -168,3 +173,60 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user