Files
webmail/app/api/plugin-secrets/__tests__/wipe.test.ts
T
Linus Rath 940a572844 feat: hide an account's mail when its login is refused, until the new password or 30 days
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.
2026-11-03 15:11:47 +01:00

71 lines
2.8 KiB
TypeScript

/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Deleting everything of one user, which only makes sense where the server and its
// files are on the device: the desktop app. On an instance the same request is a
// way to delete someone else's connected accounts, so it has to do nothing.
const deleted = vi.hoisted(() => [] as unknown[]);
const session = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (session.signedIn ? { accountName: 'me@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
}));
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
deleteUserCredentials: vi.fn(async () => {}),
listUserCredentials: vi.fn(async () => []),
putUserCredential: vi.fn(async () => {}),
}));
async function remove(query: string, headers: Record<string, string> = { 'sec-fetch-site': 'same-origin' }): Promise<Response> {
vi.resetModules();
const { DELETE } = await import('../route');
return DELETE(new NextRequest(`https://app.local/api/plugin-secrets?${query}`, { method: 'DELETE', headers }));
}
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
beforeEach(() => {
deleted.length = 0;
session.signedIn = false;
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('DELETE /api/plugin-secrets?all=true', () => {
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(200);
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
});
it('won\'t take it from another site', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403);
expect(deleted).toEqual([]);
});
it('on an instance deletes nobody\'s, with a cookie or without', async () => {
expect((await remove(EVICTED)).status).toBe(401);
session.signedIn = true;
await remove(EVICTED);
expect(deleted).toEqual([]);
});
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
expect((await remove('all=true')).status).toBe(200);
expect(deleted).toEqual([{ username: 'me@example.org', serverUrl: 'https://mail.example.org' }]);
});
});