From 3c3f6016b9954bcf8d94e3235da3bddb2db6d416 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:04:14 +0100 Subject: [PATCH] test: catch a wipe heard twice, a row put back and a mark left by a recovered refusal --- lib/devices/__tests__/registry.test.ts | 16 ++++++++++- lib/offline/__tests__/device-wipe.test.ts | 28 +++++++++++++++++++ .../__tests__/auth-store-device-wipe.test.ts | 14 +++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts index 88d2b92ae..f99b41d27 100644 --- a/lib/devices/__tests__/registry.test.ts +++ b/lib/devices/__tests__/registry.test.ts @@ -199,9 +199,23 @@ describe('the rules', () => { expect(await registry.getDevice(waiting.id)).not.toBeNull(); }); + it('prunes the user\'s own rows also in the hour after the registry-wide look', async () => { + const registry = await import('../registry'); + const start = Date.now() + 2 * 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + // Someone else's registration has the look just before the row goes stale. + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS - 30 * 60 * 1000); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(old.id)).toBeNull(); + }); + it('abandoned rows expire with no admin looking, others\' too, through any registration', async () => { const registry = await import('../registry'); - const start = Date.now() + 365 * 24 * 60 * 60 * 1000; + const start = Date.now() + 4 * 365 * 24 * 60 * 60 * 1000; vi.useFakeTimers({ now: start, toFake: ['Date'] }); const abandoned = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); const waiting = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index 72570611e..8118687a6 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -505,6 +505,8 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(state.isEvicted(ALICE)).toBe(false); expect(state.visibleAccounts()).toEqual([ALICE]); expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + // Signed in: the page says nothing from here on, even before the next look. + expect(wipeState.refusedWaits()).toEqual([]); await wipe.sweepRefusedLogins(); expect(wipeState.readRefusedLogins()).toEqual({}); @@ -717,8 +719,11 @@ describe('other windows', () => { vi.stubGlobal('location', { ...window.location, reload }); useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); state.writeEnabled([ALICE, BOB]); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); const stop = wipe.startDeviceWipe(); const other = new BroadcastChannel('bulwark-device-wipe'); + await vi.waitFor(() => expect(sent.length).toBeGreaterThan(0)); + sent.length = 0; other.postMessage({ account: BOB, phase }); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); @@ -729,6 +734,9 @@ describe('other windows', () => { expect(state.isEvicted(BOB)).toBe(false); expect(store.removed).toEqual([]); expect(purge.files).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); expect(JSON.parse(localStorage.getItem('account-registry') ?? '{"state":{"accounts":[]}}').state.accounts.map((a: { id: string }) => a.id)).not.toContain(BOB); other.close(); @@ -928,10 +936,14 @@ describe('two wipes at once', () => { }); it('a wipe heard twice is done once: the second finds the row gone', async () => { + let cleaned = 0; + registry.onDeviceWipe(() => { cleaned += 1; }); await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); const reports = sent.filter((call) => call.url.endsWith('/wiped')).length; expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(reports); expect(wipeState.consumeWipeNote()).toBeNull(); expect(wipeState.readPendingWipes()).toEqual({}); @@ -960,6 +972,16 @@ describe('a sign-out', () => { expect(wipeState.readWipeConfirmations()).toEqual([]); }); + it('has its row on the way before the key database goes with the rest', async () => { + respond = () => json(503, {}); + let queuedAtPurge: string[] | null = null; + purge.during = () => { queuedAtPurge ??= wipeState.readWipeConfirmations().map((entry) => entry.id); }; + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(queuedAtPurge).toEqual([ROW]); + }); + it('removing another account drops its row too, and keeps this one\'s', async () => { useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); @@ -1098,6 +1120,12 @@ describe('what else goes with a wipe', () => { await wipe.wipeAccountFromDevice(ALICE, 'admin'); expect(closed).toEqual(['From Eve']); expect(unsubscribed).toBe(0); + // No full sign-out comes along to clean up behind it. A store may write itself back, empty. + for (const key of ['pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + 'bulwark-delegate-names', 'calendar-notification-storage', 'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage']) { + expect([key, localStorage.getItem(key)]).not.toEqual([key, 'x']); + } + expect(deletedCaches).toContain('bulwark-push-state-v1'); }); it('a wipe from settings takes them too', async () => { diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index ec7706b62..1f1d9a00b 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -74,6 +74,14 @@ function account(id: string, slot: number, authMode: 'basic' | 'oauth') { }; } +/** How accounts leave: 'explicit' is what takes their data with it. */ +async function removalReasons(): Promise { + const { onAccountRemoved } = await import('@/stores/account-store'); + const reasons: string[] = []; + onAccountRemoved((_id, reason) => { reasons.push(reason); }); + return reasons; +} + type Answer = (url: string, method: string) => Response | null; let answer: Answer; const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); @@ -165,11 +173,13 @@ describe('a login the server ended, at a start', () => { }); it('invalid_grant fires the hook, for certain, and only hides the data', async () => { + const reasons = await removalReasons(); useOfflineStore.setState({ enabled: [ALICE] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().checkAuth(); // With mail kept here the real connect goes on behind the local copy. await vi.waitFor(() => expect(heard).toEqual([ALICE])); + await vi.waitFor(() => expect(reasons).toEqual(['evicted'])); expect(certain).toEqual([true]); expect(isEvicted(ALICE)).toBe(true); expect(useOfflineStore.getState().enabled).toEqual([ALICE]); @@ -218,10 +228,12 @@ describe('a login the server ended, at a switch', () => { }); it('invalid_grant fires the hook and hides the account, keeping its data', async () => { + const reasons = await removalReasons(); useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().switchAccount(BOB); expect(heard).toEqual([BOB]); + expect(reasons).toEqual(['evicted']); expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); expect(isEvicted(BOB)).toBe(true); expect(useOfflineStore.getState().enabled).toContain(BOB); @@ -282,7 +294,7 @@ describe('a password the server keeps refusing', () => { it('during the connect: a later failure isn\'t read as a refusal', async () => { clients.connect = async (client) => { - if ((client as { username?: string }).username === 'bob') (client as { refused?: (ms: number) => void }).refused?.(0); + (client as { refused?: (ms: number) => void }).refused?.(0); }; await useAuthStore.getState().checkAuth(); await vi.waitFor(() => expect(clients.made.length).toBeGreaterThanOrEqual(2));