fix(security): persistent search index forgets redacted and left-room text

Nothing ever removed an indexed row: redacted messages stayed searchable
with full plaintext and rendered as normal results. Now: a client-level
RoomEvent.Redaction listener deletes the row, leave/ban clears the room
(clearRoom finally has a caller), m.replace edits upsert the original
row instead of indexing the "* fallback" separately, and cached rows
whose local event is redacted render through the existing
redacted_because placeholder. Unit-tested.

Fixes #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:05 -04:00
co-authored by Claude Opus 5
parent 9bdf4ff1fd
commit c8e49d3855
5 changed files with 214 additions and 21 deletions
+27
View File
@@ -10,6 +10,7 @@ import {
saveRoomIndex,
clearRoom,
clearAll,
deleteRow,
deleteSearchCacheDatabase,
SearchCacheRow,
} from './searchCache';
@@ -128,6 +129,31 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
await deleteSearchCacheDatabase();
});
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 deleteSearchCacheDatabase();
});
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.
@@ -138,6 +164,7 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
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());
});