Files
webmail/lib/jmap/__tests__/credentials-refused.test.ts
T
Linus Rath 8523289a4b feat: let an admin wipe a lost device, and clear an account the server refuses for good
Until now a lost laptop kept its offline mail for as long as anyone could
open it, and an account whose login the server had ended kept its copy too.

- `lib/devices`, `app/api/devices`: browsers with offline on and desktop
  apps register, in a durable collection with no expiry. A check-in goes
  by a per-device secret, so a revoked device still hears its wipe.
- `app/api/admin/devices` and the org devices page: wipe one device or all
  of a user's, with audit entries for each request and each confirmation.
- `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then
  waits up to a second for the instance before showing the local copy. A
  wipe takes the store, keys, plugin databases and handoffs, signs out,
  and reports back, again at the next contact if the report failed.
- `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the
  session limit, or a 401 lasting past a re-auth count as refused. A 500,
  a proxy page or an outage never do.
- Settings: "Remove everything from this device", with a confirmation.

`lib/shared-store/__tests__/import-data.test.ts` changes its assertion
because it lists every collection the import copies, and `devices` is new.
2026-11-03 13:19:44 +01:00

168 lines
6.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../client';
// A 401 after a fresh try at signing in is the one signal a password login has
// that its credentials are over. So what else looks like one must not count.
const SERVER = 'https://mail.example.test';
const problem = () => new Response(JSON.stringify({ type: 'about:blank', status: 401, title: 'Unauthorized', detail: 'You have to authenticate first.' }), { status: 401, headers: { 'Content-Type': 'application/problem+json' } });
const html401 = () => new Response('<html>Sign in to the proxy</html>', { status: 401, headers: { 'Content-Type': 'text/html' } });
const session = () => new Response(JSON.stringify({
apiUrl: `${SERVER}/jmap`, downloadUrl: `${SERVER}/d`, uploadUrl: `${SERVER}/u`, eventSourceUrl: '', username: 'alice',
capabilities: { 'urn:ietf:params:jmap:core': {} }, accounts: { a: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
const echo = () => new Response(JSON.stringify({ methodResponses: [['Core/echo', { ping: 'pong' }, '0']], sessionState: 's' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
let answers: { api: () => Response; session: () => Response };
function stubServer(): void {
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/.well-known/jmap') ? answers.session() : answers.api())));
}
async function connectedBasic(): Promise<{ client: JMAPClient; refused: number[] }> {
answers = { session, api: echo };
const client = new JMAPClient(SERVER, 'alice', 'secret');
await client.connect();
const refused: number[] = [];
client.onCredentialsRefused((ms) => refused.push(ms));
return { client, refused };
}
beforeEach(() => {
vi.useRealTimers();
stubServer();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('a password login', () => {
it('counts when the session URL refuses too', async () => {
const { client, refused } = await connectedBasic();
answers = { api: problem, session: problem };
await client.ping().catch(() => {});
expect(refused).toHaveLength(1);
client.disconnect();
});
it('counts when the session answers and the retry is refused', async () => {
const { client, refused } = await connectedBasic();
answers = { api: problem, session };
await client.ping().catch(() => {});
expect(refused).toHaveLength(1);
client.disconnect();
});
it('doesn\'t count when the session URL gives a 500: that says nothing about the password', async () => {
const { client, refused } = await connectedBasic();
answers = { api: problem, session: () => new Response('oops', { status: 500 }) };
await client.ping().catch(() => {});
expect(refused).toEqual([]);
client.disconnect();
});
it('doesn\'t count when the network breaks on the way', async () => {
const { client, refused } = await connectedBasic();
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
if (String(input).includes('/.well-known/jmap')) throw new TypeError('Failed to fetch');
return problem();
}));
await client.ping().catch(() => {});
expect(refused).toEqual([]);
client.disconnect();
});
it('doesn\'t count a proxy\'s page', async () => {
const { client, refused } = await connectedBasic();
answers = { api: html401, session: html401 };
await client.ping().catch(() => {});
expect(refused).toEqual([]);
client.disconnect();
});
it('doesn\'t count a plain 500, or a 403', async () => {
const { client, refused } = await connectedBasic();
answers = { api: () => new Response('nope', { status: 500 }), session };
await client.ping().catch(() => {});
answers = { api: () => new Response(JSON.stringify({ status: 403 }), { status: 403 }), session };
await client.ping().catch(() => {});
expect(refused).toEqual([]);
client.disconnect();
});
it('counts at connect, so a restore can tell', async () => {
answers = { api: echo, session: problem };
const client = new JMAPClient(SERVER, 'alice', 'secret');
const refused: number[] = [];
client.onCredentialsRefused((ms) => refused.push(ms));
await expect(client.connect()).rejects.toThrow('Invalid username or password');
expect(refused).toHaveLength(1);
});
it('never counts with a one-time code: those go stale by themselves', async () => {
const { client, refused } = await connectedBasic();
client.enableTotpReauth('secret', async () => null);
answers = { api: problem, session: problem };
await client.ping().catch(() => {});
expect(refused).toEqual([]);
client.disconnect();
});
it('measures from the first refusal, and a good answer starts it over', async () => {
const { client, refused } = await connectedBasic();
const now = vi.spyOn(Date, 'now');
now.mockReturnValue(1_000_000);
answers = { api: problem, session: problem };
await client.ping().catch(() => {});
now.mockReturnValue(1_090_000);
await client.ping().catch(() => {});
expect(refused).toEqual([0, 90_000]);
answers = { api: echo, session };
now.mockReturnValue(1_100_000);
await client.ping();
answers = { api: problem, session: problem };
now.mockReturnValue(1_200_000);
await client.ping().catch(() => {});
expect(refused).toEqual([0, 90_000, 0]);
client.disconnect();
});
});
describe('a token login', () => {
function bearer(renewed: string | null): { client: JMAPClient; refused: number[]; renew: ReturnType<typeof vi.fn> } {
const renew = vi.fn(async () => renewed);
const client = JMAPClient.withBearer(SERVER, 'old', 'alice', renew);
const refused: number[] = [];
client.onCredentialsRefused((ms) => refused.push(ms));
return { client, refused, renew };
}
it('counts when a token issued just now is refused', async () => {
answers = { api: problem, session: problem };
const { client, refused, renew } = bearer('new');
await expect(client.connect()).rejects.toThrow();
expect(renew).toHaveBeenCalled();
expect(refused).toHaveLength(1);
});
it('doesn\'t count when no new token came: the renewal has its own rules', async () => {
answers = { api: problem, session: problem };
const { client, refused } = bearer(null);
await expect(client.connect()).rejects.toThrow();
expect(refused).toEqual([]);
});
it('counts for a pasted token, which nothing can renew', async () => {
answers = { api: problem, session: problem };
const client = JMAPClient.withBearer(SERVER, 'pasted', 'alice');
const refused: number[] = [];
client.onCredentialsRefused((ms) => refused.push(ms));
await expect(client.connect()).rejects.toThrow();
expect(refused).toHaveLength(1);
});
});