Files
webmail/lib/__tests__/jmap-calendar-access.test.ts
T

159 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// The calendar fan-out probes shared accounts on suspicion (Stalwart does not
// always advertise calendar capability on group accounts), so a shared account
// without any calendar access answers every probe with an access rejection.
// That rejection must be remembered and the account skipped afterwards -
// before, every calendar interaction re-probed it and logged a console error.
function makeSession() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:calendars': {} },
accounts: {
'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} },
// Shared account without calendar access: probed because it is
// non-personal, rejected by the server.
'ev': { name: 'shared', isPersonal: false, accountCapabilities: {} },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: '',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('calendar fan-out to shared accounts without access', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
beforeEach(async () => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}'));
const [method, args] = body.methodCalls?.[0] ?? [];
if (args?.accountId === 'ev') {
return jsonResponse({
methodResponses: [
['error', { type: 'accountNotFound', description: 'You do not have access to account ev' }, '0'],
],
});
}
if (method === 'CalendarEvent/query') {
return jsonResponse({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']] });
}
return jsonResponse({ methodResponses: [['Calendar/get', { list: [] }, '0']] });
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
client.disconnect();
consoleErrorSpy.mockRestore();
fetchSpy.mockRestore();
});
function callsForAccount(accountId: string): unknown[][] {
return fetchSpy.mock.calls.filter((call: unknown[]) => {
const body = (call[1] as RequestInit | undefined)?.body;
return typeof body === 'string' && body.includes(`"accountId":"${accountId}"`);
});
}
it('probes a no-access shared account once, then skips it without console noise', async () => {
await client.queryAllCalendarEvents({});
await client.queryAllCalendarEvents({});
expect(callsForAccount('ev')).toHaveLength(1);
// The primary account keeps being queried normally.
expect(callsForAccount('acct-1')).toHaveLength(2);
// An expected rejection is not an error worth red console output.
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('getAllCalendars skips an account already known to be inaccessible', async () => {
await client.queryAllCalendarEvents({});
fetchSpy.mockClear();
await client.getAllCalendars();
expect(callsForAccount('ev')).toHaveLength(0);
expect(callsForAccount('acct-1')).toHaveLength(1);
});
});
describe('calendar fan-out when an account fails', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
let failing: Record<string, { type: string; description: string }>;
beforeEach(async () => {
failing = {};
fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}'));
const methodResponses = (body.methodCalls ?? []).map(([method, args, callId]: [string, Record<string, unknown>, string]) => {
const failure = failing[args.accountId as string];
if (failure) return ['error', failure, callId];
if (method === 'CalendarEvent/query') return ['CalendarEvent/query', { ids: ['e1'] }, callId];
return ['CalendarEvent/get', {
list: [{ id: 'e1', '@type': 'Event', title: `Event of ${args.accountId}`, start: '2026-09-22T10:00:00', calendarIds: { c: true } }],
}, callId];
});
return jsonResponse({ methodResponses });
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
client.disconnect();
consoleErrorSpy.mockRestore();
fetchSpy.mockRestore();
});
it('fails the whole query when the user’s own calendars fail on Stalwart 1.0', async () => {
failing['acct-1'] = { type: 'invalidArguments', description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd' };
await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/recurrenceOverrides cannot be requested/);
});
it('does not take a refusal of the user’s own account for a share without access on 0.16', async () => {
failing['acct-1'] = { type: 'forbidden', description: 'You do not have access to this account' };
await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/do not have access/);
// Not remembered as "no access": the next load asks again.
delete failing['acct-1'];
const events = await client.queryAllCalendarEvents({});
expect(events.map((e) => e.title)).toEqual(['Event of acct-1', 'Event of ev']);
});
it('leaves out a shared account that fails for another reason and keeps the rest on 0.16', async () => {
failing.ev = { type: 'serverFail', description: 'Temporary failure' };
const events = await client.queryAllCalendarEvents({});
expect(events.map((e) => e.title)).toEqual(['Event of acct-1']);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
});