Files
webmail/lib/__tests__/jmap-archived-items.test.ts
T
Linus Rath 4d846cfc91 fix: read the whole deleted items archive newest first and only for the account shown #1319
Stalwart's x:ArchivedItem/query without calculateTotal scans ids
ascending, stops at 5000 and only then reverses them, so the newest
items of a large archive never showed, and admin accounts got every
account's items. The review of the branch also found gaps around
account switches, restores and focus.

- Read the archive in pages of 500 with calculateTotal, newest first,
  stop at a whole page older than the days limit, and offer
  "Show older" after 2000 items; detection asks for one item.
- Send the accountId filter and fall back without it on
  unsupportedFilter; keep only items of the account, never one without
  an account id.
- Ask nothing until config and policy are loaded and the policy is the
  current account's; the later check after a refusal is forced.
- Show the Trash link only when the archive holds a message inside the
  days limit; the Settings page shows whenever the server has the
  feature and says that archiving must be on in Stalwart.
- Restore: notFound leaves the list with a note, forbidden hides the
  page, results for another account are dropped, the button keeps focus.
- Forget what servers said at a full sign-out.
2026-10-28 09:08:44 +01:00

128 lines
6.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { jmapErrorType } from '../jmap/jmap-error';
import { ACCOUNT, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from '../recovery/__tests__/fixtures';
type Call = [string, Record<string, unknown>, string];
type Body = { using: string[]; methodCalls: Call[] };
function client(core: Record<string, number> = {}, stalwart = true) {
const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
const accounts = {
[ACCOUNT]: {
name: 'org-alice@example.org', isPersonal: true,
accountCapabilities: stalwart ? { 'urn:ietf:params:jmap:mail': {}, 'urn:stalwart:jmap': {} } : { 'urn:ietf:params:jmap:mail': {} },
},
};
Object.assign(c, {
apiUrl: 'https://jmap.example.com/api',
accountId: ACCOUNT,
capabilities: { 'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, ...core } },
accounts,
session: { accounts },
});
return c;
}
function respondWith(handler: (body: Body) => unknown) {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string) as Body;
const response = handler(body);
return { ok: true, status: 200, text: async () => JSON.stringify(response), json: async () => response } as Response;
});
}
const bodies = (spy: ReturnType<typeof respondWith>) => spy.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string) as Body);
const queries = (spy: ReturnType<typeof respondWith>) => bodies(spy).flatMap((b) => b.methodCalls).filter(([m]) => m === 'x:ArchivedItem/query');
afterEach(() => vi.restoreAllMocks());
describe('JMAPClient archived items', () => {
it('advertises them only on Stalwart', () => {
expect(client().advertisesArchivedItems()).toBe(true);
expect(client({}, false).advertisesArchivedItems()).toBe(false);
});
it('asks the account filter first and, once refused, never again for that account', async () => {
const server = fakeArchiveServer([archivedEmail('a')]);
const spy = respondWith(server.answer);
const c = client();
await expect(c.queryArchivedItemIds(undefined, { limit: 1 })).resolves.toEqual({ ids: ['a'], total: null });
await c.queryArchivedItemIds(undefined, { limit: 1 });
expect(queries(spy).map(([, args]) => args)).toEqual([
{ accountId: ACCOUNT, limit: 1, filter: { accountId: ACCOUNT } },
{ accountId: ACCOUNT, limit: 1 },
{ accountId: ACCOUNT, limit: 1 },
]);
expect(bodies(spy)[0].using).toEqual(['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']);
});
it("keeps an admin's answer to the account through the filter, and drops other accounts' items anyway", async () => {
const server = fakeArchiveServer([
archivedEmail('i3'),
archivedEmail('i2', { accountId: 'cz' }),
archivedEmail('i1', { accountId: undefined }),
], { impersonate: true });
const spy = respondWith(server.answer);
const result = await client().listArchivedEmails(undefined, { days: 0 });
expect(result.items.map((i) => i.id)).toEqual(['i3']);
expect(queries(spy).every(([, args]) => (args.filter as { accountId?: string })?.accountId === ACCOUNT)).toBe(true);
});
it('drops the other accounts in an unfiltered mixed answer', async () => {
// A server that ignores the filter and answers with every account.
respondWith((body) => ({
methodResponses: body.methodCalls.map(([method, , id]) => method === 'x:ArchivedItem/query'
? [method, { ids: ['a', 'z', 'n'], total: 3 }, id]
: [method, { list: [archivedEmail('a'), archivedEmail('z', { accountId: 'cz' }), archivedEmail('n', { accountId: undefined })] }, id]),
}));
const result = await client().listArchivedEmails(undefined, { days: 0 });
expect(result.items.map((i) => i.id)).toEqual(['a']);
});
it('throws the refusal with its type when the server has no license', async () => {
respondWith(() => ({ methodResponses: [['error', LICENSE_REFUSAL, 'q']] }));
const error = await client().queryArchivedItemIds().catch((e: unknown) => e);
expect(jmapErrorType(error)).toBe('forbidden');
});
it('pages with calculateTotal, so the newest of more than 5000 items comes first', async () => {
const now = Date.now();
const server = fakeArchiveServer(bigArchive(6000, now));
const spy = respondWith(server.answer);
const first = await client().listArchivedEmails(undefined, { days: 0 });
expect(first.items[0].id).toBe('i006000');
expect(first.nextPosition).toBe(2000);
const pages = queries(spy).filter(([, args]) => args.calculateTotal && !args.filter);
expect(pages.map(([, args]) => [args.position ?? 0, args.limit])).toEqual([[0, 500], [500, 500], [1000, 500], [1500, 500]]);
const gets = bodies(spy).flatMap((b) => b.methodCalls).filter(([m]) => m === 'x:ArchivedItem/get');
expect(gets.every(([, args]) => (args.ids as string[]).length <= 500)).toBe(true);
const older = await client().listArchivedEmails(undefined, { days: 0, startPosition: 2000 });
expect(older.items[0].id).toBe('i004000');
});
it('checks for the Trash link with one-item pages and stops at the first message', async () => {
const now = Date.now();
const server = fakeArchiveServer(bigArchive(30, now));
const spy = respondWith(server.answer);
await expect(client().hasArchivedEmail(undefined, { days: 7 })).resolves.toBe(true);
expect(queries(spy).filter(([, args]) => args.calculateTotal && !args.filter).map(([, args]) => args.limit)).toEqual([1]);
});
it('restores with requestRestore and reports notFound and failures per item', async () => {
const server = fakeArchiveServer([archivedEmail('a')]);
const spy = respondWith(server.answer);
const results = await client().restoreArchivedItems(['a', 'b']);
expect(results).toEqual([{ id: 'a', ok: true }, { id: 'b', ok: false, error: 'notFound', type: 'notFound' }]);
expect(bodies(spy)[0].methodCalls).toEqual([
['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'],
]);
});
it('fails every item of a request that fails instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
const results = await client().restoreArchivedItems(['a']);
expect(results).toEqual([{ id: 'a', ok: false, error: expect.any(String) }]);
});
});