mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Stalwart 1.0 fails every CalendarEvent get and query whose `timeZone` argument names a zone missing from its time zone database, with invalidArguments "Invalid timeZone". 0.16 reads such a zone as UTC, so the calendar kept working there; on 1.0 it would stay empty. - check the user's zone with a get of no events that rides along with the session's first calendar list - once the server refuses the zone, ask in UTC, move the grid's wall-clock range bounds to UTC as well, and log one console warning - repeat a read that was refused before the check answered, once, in UTC
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
/**
|
|
* #907: Stalwart lazily creates an account's default calendar / address book on
|
|
* the first request that touches the collection. When Bulwark's login burst
|
|
* fires Calendar/get and CalendarEvent/query concurrently against a clustered
|
|
* server, each node can create its own default - duplicating the "Personal"
|
|
* calendar. The client must therefore not put a second calendar (or contacts)
|
|
* request on the wire until the first one for that account has settled, while
|
|
* leaving unrelated requests (Mailbox/get) unaffected.
|
|
*/
|
|
|
|
function makeSession() {
|
|
return {
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:core': {},
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:calendars': {},
|
|
'urn:ietf:params:jmap:contacts': {},
|
|
},
|
|
accounts: {
|
|
'acct-1': {
|
|
name: 'test',
|
|
isPersonal: true,
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:calendars': {},
|
|
'urn:ietf:params:jmap:contacts': {},
|
|
},
|
|
},
|
|
},
|
|
primaryAccounts: {
|
|
'urn:ietf:params:jmap:mail': 'acct-1',
|
|
'urn:ietf:params:jmap:calendars': 'acct-1',
|
|
'urn:ietf:params:jmap:contacts': '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: 'https://mail.example.com/jmap/eventsource',
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
interface Pending {
|
|
methods: string[];
|
|
resolve: (body: unknown) => void;
|
|
}
|
|
|
|
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
|
|
describe('JMAPClient first-touch ordering (#907)', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
let onWire: Pending[];
|
|
|
|
beforeEach(async () => {
|
|
onWire = [];
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
async function connectedClient(): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
|
|
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
// Every API call from here on is parked until the test releases it, in the
|
|
// order it reached the (mock) network.
|
|
fetchSpy.mockImplementation((_url: string, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as { methodCalls?: [string][] };
|
|
return new Promise<Response>((resolve) => {
|
|
onWire.push({
|
|
methods: (body.methodCalls ?? []).map((c) => c[0]),
|
|
resolve: (responseBody) => resolve(jsonResponse(responseBody)),
|
|
});
|
|
});
|
|
});
|
|
return client;
|
|
}
|
|
|
|
const emptyResponse = (method: string) => ({ methodResponses: [[method, { list: [], ids: [] }, '0']] });
|
|
|
|
it('holds CalendarEvent/query until the first Calendar/get for the account has settled', async () => {
|
|
const client = await connectedClient();
|
|
|
|
const calendars = client.getAllCalendars();
|
|
const events = client.queryCalendarEvents({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' });
|
|
await flush();
|
|
|
|
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get']);
|
|
|
|
onWire[0].resolve(emptyResponse('Calendar/get'));
|
|
await expect(calendars).resolves.toEqual([]);
|
|
await flush();
|
|
|
|
// The synthetic-id probe (lib/recurrence-instances.ts) rides along in
|
|
// the Calendar/get request, and so does the time zone check unless the
|
|
// zone is UTC: the query needs no request of its own first.
|
|
expect(onWire[0].methods.slice(0, 2)).toEqual(['Calendar/get', 'CalendarEvent/set']);
|
|
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get', 'CalendarEvent/query']);
|
|
onWire[1].resolve(emptyResponse('CalendarEvent/query'));
|
|
await expect(events).resolves.toEqual([]);
|
|
});
|
|
|
|
it('does not delay unrelated requests, nor contacts behind calendars', async () => {
|
|
const client = await connectedClient();
|
|
|
|
const calendars = client.getAllCalendars();
|
|
const mailboxes = client.getMailboxes();
|
|
const books = client.getAddressBooks();
|
|
await flush();
|
|
|
|
expect(onWire.map((p) => p.methods[0]).sort()).toEqual(['AddressBook/get', 'Calendar/get', 'Mailbox/get']);
|
|
|
|
for (const p of onWire) p.resolve(emptyResponse(p.methods[0]));
|
|
await Promise.all([calendars, mailboxes, books]);
|
|
});
|
|
|
|
it('lets calendar requests run concurrently after the first touch', async () => {
|
|
const client = await connectedClient();
|
|
|
|
const warm = client.getAllCalendars();
|
|
await flush();
|
|
onWire[0].resolve(emptyResponse('Calendar/get'));
|
|
await warm;
|
|
onWire = [];
|
|
|
|
const a = client.queryCalendarEvents({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' });
|
|
const b = client.getAllCalendars();
|
|
await flush();
|
|
|
|
expect(onWire.map((p) => p.methods[0]).sort()).toEqual(['Calendar/get', 'CalendarEvent/query']);
|
|
for (const p of onWire) p.resolve(emptyResponse(p.methods[0]));
|
|
await Promise.all([a, b]);
|
|
});
|
|
});
|