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:
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SearchCacheRow } from '../../utils/searchCache';
|
||||
|
||||
// useLocalMessageSearch.ts imports searchCacheEnabledAtom, which touches
|
||||
// localStorage at module-load time (atomWithLocalStorage reads the initial
|
||||
// value eagerly). Stub it before a dynamic import — a static import would
|
||||
// hoist above the stub. Same pattern as state/plaintextCaches.test.ts.
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
|
||||
const { rowToResultItem } = await import('./useLocalMessageSearch');
|
||||
|
||||
const row = (overrides: Partial<SearchCacheRow> = {}): SearchCacheRow => ({
|
||||
roomId: '!r1',
|
||||
eventId: '$1',
|
||||
ts: 100,
|
||||
sender: '@a',
|
||||
body: 'hello world',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Gitea #14 — cached rows for a locally-known-redacted event must carry a
|
||||
// `redacted_because` marker so SearchResultGroup's guard renders the
|
||||
// "message deleted" placeholder instead of the stale plaintext.
|
||||
|
||||
test('rowToResultItem: plain row has no redacted_because marker', () => {
|
||||
const item = rowToResultItem(row());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).unsigned?.redacted_because, undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).content.body, 'hello world');
|
||||
});
|
||||
|
||||
test('rowToResultItem: redacted=true sets the redacted_because marker', () => {
|
||||
const item = rowToResultItem(row(), true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.ok((item.event as any).unsigned?.redacted_because);
|
||||
});
|
||||
|
||||
test('rowToResultItem: falls back to pollText when body is empty', () => {
|
||||
const item = rowToResultItem(row({ body: '', pollText: 'question answer' }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).content.body, 'question answer');
|
||||
});
|
||||
|
||||
test('rowToResultItem: carries formattedBody as HTML when present', () => {
|
||||
const item = rowToResultItem(row({ formattedBody: '<b>hi</b>' }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const content = (item.event as any).content;
|
||||
assert.equal(content.format, 'org.matrix.custom.html');
|
||||
assert.equal(content.formatted_body, '<b>hi</b>');
|
||||
});
|
||||
Reference in New Issue
Block a user