test: catch a wipe heard twice, a row put back and a mark left by a recovered refusal

This commit is contained in:
Linus Rath
2026-11-03 16:04:14 +01:00
parent 940a572844
commit 3c3f6016b9
3 changed files with 56 additions and 2 deletions
+15 -1
View File
@@ -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);
+28
View File
@@ -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 () => {
@@ -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<string[]> {
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));