mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
A refused login used to delete the account's data, and with it everyone's S/MIME and OpenPGP keys, also with offline off. The usual cause is a password changed on another device, which Stalwart 0.16 and 1.0 both answer with `invalid_grant`, so that lost unsent mail for little protection. - A refused login signs out at once and hides the data. A new password brings it back, unsent mail included. Otherwise it goes after 30 days. The sign-in page gives a count and a date. The 24-hour timer is gone. - `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion at once, of that account's own data only. - The session limit no longer counts as a refusal on any route. It hides the data and says nothing. - A wipe runs under a Web Lock, tells other windows when it starts and ends, and queues its report before deleting anything. It reports only once the deletion worked and no plugin database is left. - More goes with a wipe: Pro tabs, search history, Files and WebDAV state, templates, pop-out drafts, shown notifications, push state, and Web Push with the last account. - The unsent count is per account, and unknown rather than 0 when the store can't say. A new row is checked against the account that asked, and a sign-out drops its row. Existing tests changed because the rule did: `auth-store-keep-local.test.ts` expected a wipe on `invalid_grant` and at the session limit, and `device-wipe.test.ts` expected shared keys to go on a refused login.
82 lines
3.5 KiB
TypeScript
82 lines
3.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// A dropped account's plugin secrets, on the desktop app's own server. It has no
|
|
// cookie left, so what the account store wrote down at the time is all there is.
|
|
|
|
const ALICE = 'alice@mail.example.test';
|
|
const entry = {
|
|
id: ALICE, label: ALICE, serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'basic', rememberMe: true,
|
|
displayName: ALICE, email: ALICE, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: true, cookieSlot: 0, avatarColor: '#000',
|
|
};
|
|
|
|
async function load(desktop: boolean) {
|
|
vi.resetModules();
|
|
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', desktop ? '1' : '');
|
|
const { useAccountStore } = await import('@/stores/account-store');
|
|
const state = await import('../state');
|
|
const { forgetEvictedSecrets } = await import('../evicted-secrets');
|
|
useAccountStore.setState({ accounts: [entry], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
|
state.writeEnabled([ALICE]);
|
|
return { useAccountStore, state, forgetEvictedSecrets };
|
|
}
|
|
|
|
let calls: Array<{ url: string; method?: string }>;
|
|
let answer: () => Response;
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
calls = [];
|
|
answer = () => new Response('{"ok":true}', { status: 200 });
|
|
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
calls.push({ url: String(input), method: init?.method });
|
|
return answer();
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('a dropped account\'s plugin secrets', () => {
|
|
it('in the desktop app go by who it was, with no cookie', async () => {
|
|
const { useAccountStore, state, forgetEvictedSecrets } = await load(true);
|
|
useAccountStore.getState().removeAccount(ALICE, 'evicted');
|
|
expect(state.readEvictedOwners()[ALICE]).toEqual({ username: 'alice', serverUrl: 'https://mail.example.test' });
|
|
expect(JSON.stringify(state.readEvictedOwners())).not.toContain('password');
|
|
|
|
await forgetEvictedSecrets(ALICE);
|
|
expect(calls).toHaveLength(1);
|
|
const url = new URL(calls[0].url, 'https://desktop.local');
|
|
expect(calls[0].method).toBe('DELETE');
|
|
expect(url.pathname).toBe('/api/plugin-secrets');
|
|
expect(Object.fromEntries(url.searchParams)).toEqual({ all: 'true', username: 'alice', server: 'https://mail.example.test' });
|
|
expect(state.readEvictedOwners()).toEqual({});
|
|
});
|
|
|
|
it('are asked for again when the server couldn\'t', async () => {
|
|
const { useAccountStore, state, forgetEvictedSecrets } = await load(true);
|
|
useAccountStore.getState().removeAccount(ALICE, 'evicted');
|
|
answer = () => new Response('{}', { status: 503 });
|
|
await forgetEvictedSecrets(ALICE);
|
|
expect(Object.keys(state.readEvictedOwners())).toEqual([ALICE]);
|
|
});
|
|
|
|
it('stay when the account signs in again, and nobody remembers it was dropped', async () => {
|
|
const { useAccountStore, state, forgetEvictedSecrets } = await load(true);
|
|
useAccountStore.getState().removeAccount(ALICE, 'evicted');
|
|
useAccountStore.getState().addAccount(entry as never);
|
|
expect(state.readEvictedOwners()).toEqual({});
|
|
await forgetEvictedSecrets(null);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it('on an instance aren\'t this device\'s: nothing is written down or asked', async () => {
|
|
const { useAccountStore, state, forgetEvictedSecrets } = await load(false);
|
|
useAccountStore.getState().removeAccount(ALICE, 'evicted');
|
|
expect(state.readEvictedOwners()).toEqual({});
|
|
await forgetEvictedSecrets(null);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
});
|