Several localStorage caches held decrypted message content or user PII and survived a normal logout, leaving residue on a shared device (the search index was already wiped; these were not): - cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends - cinny_recent_searches_v1 - search query text - cinny_recent_forward_targets_v1 - recent forward contact/room graph - cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent - navToActivePath<userId> - per-space last-visited room paths - (plus the translation cache added earlier) Add a clear function per module and a single auditable clearPlaintextCaches() aggregator, called from both logout paths (logoutClient + the server-forced SessionLoggedOut handler) alongside the existing session/search-index wipes. Unit-tested. Deliberately NOT cleared (documented in the aggregator): unsent composer drafts and the presence status message (preserved by product decision N98); SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes), already wiped by mx.clearStores(); low-sensitivity UI/metadata residue. The forward-targets/gifs/stickers/nav-path additions and the accurate "not covered" documentation address findings from two review passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
// These modules touch localStorage at import/runtime. Provide a minimal mock
|
||
// that records removed keys, then import dynamically (a static import would
|
||
// hoist above the mock).
|
||
const removed: string[] = [];
|
||
const store = new Map<string, string>();
|
||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||
getItem: (k: string) => store.get(k) ?? null,
|
||
setItem: (k: string, v: string) => {
|
||
store.set(k, v);
|
||
},
|
||
removeItem: (k: string) => {
|
||
removed.push(k);
|
||
store.delete(k);
|
||
},
|
||
};
|
||
|
||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
||
|
||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||
store.clear();
|
||
store.set('cinny_translation_cache_v1', '[]');
|
||
store.set('cinny_scheduled_messages_v1', '{}');
|
||
store.set('cinny_recent_searches_v1', '[]');
|
||
store.set('cinny_recent_forward_targets_v1', '[]');
|
||
store.set('cinny_recent_gifs_v1', '[]');
|
||
store.set('cinny_recent_stickers_v1', '[]');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches();
|
||
|
||
for (const key of [
|
||
'cinny_translation_cache_v1',
|
||
'cinny_scheduled_messages_v1',
|
||
'cinny_recent_searches_v1',
|
||
'cinny_recent_forward_targets_v1',
|
||
'cinny_recent_gifs_v1',
|
||
'cinny_recent_stickers_v1',
|
||
]) {
|
||
assert.ok(removed.includes(key), `${key} cleared`);
|
||
}
|
||
assert.equal(store.size, 0, 'all keys gone from store');
|
||
});
|
||
|
||
test('clearPlaintextCaches clears the per-user nav-path store only when given a userId', () => {
|
||
store.clear();
|
||
store.set('navToActivePath@me:server', '{}');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches(); // no userId -> nav path untouched
|
||
assert.ok(!removed.includes('navToActivePath@me:server'), 'nav path kept without userId');
|
||
|
||
clearPlaintextCaches('@me:server');
|
||
assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId');
|
||
});
|
||
|
||
test('clearPlaintextCaches does NOT touch drafts or session keys', () => {
|
||
store.clear();
|
||
store.set('draft-msg-!room:server', '{"body":"unsent"}');
|
||
store.set('cinny_session', '{"accessToken":"x"}');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches('@me:server');
|
||
|
||
assert.ok(!removed.includes('draft-msg-!room:server'), 'draft preserved (N98)');
|
||
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
|
||
assert.ok(store.has('draft-msg-!room:server'));
|
||
});
|