Merge pull request #677 from dealerweb/fix/calendar-fanout-access-denied
Fix: stop re-probing shared accounts without calendar access
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
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': {} },
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-1
@@ -4566,6 +4566,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
for (const accountId of accountIds) {
|
for (const accountId of accountIds) {
|
||||||
const isPrimary = accountId === primaryId;
|
const isPrimary = accountId === primaryId;
|
||||||
|
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||||||
const account = this.accounts[accountId];
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4772,6 +4773,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
.map((event) => normalizeCalendarEventLike(event));
|
.map((event) => normalizeCalendarEventLike(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared accounts the server rejected calendar access for - probed once,
|
||||||
|
// then skipped for the rest of the session (see getCalendarCapableAccountIds
|
||||||
|
// for why the fan-out has to probe on suspicion).
|
||||||
|
private calendarAccessDenied = new Set<string>();
|
||||||
|
|
||||||
async queryAllCalendarEvents(
|
async queryAllCalendarEvents(
|
||||||
filter: CalendarEventFilter,
|
filter: CalendarEventFilter,
|
||||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||||
@@ -4784,6 +4790,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
for (const accountId of accountIds) {
|
for (const accountId of accountIds) {
|
||||||
const isPrimary = accountId === primaryId;
|
const isPrimary = accountId === primaryId;
|
||||||
|
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||||||
const account = this.accounts[accountId];
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4849,7 +4856,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||||
const error = queryResponse.methodResponses[0][1];
|
const error = queryResponse.methodResponses[0][1];
|
||||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
// Keep the JMAP error type so the catch below can tell an expected
|
||||||
|
// access rejection apart from a genuine failure.
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(error?.description || error?.type || "CalendarEvent/query failed"),
|
||||||
|
{ jmapErrorType: error?.type },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||||||
@@ -4893,6 +4905,18 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// The fan-out over shared accounts probes on suspicion (see
|
||||||
|
// getCalendarCapableAccountIds) and may hit accounts that grant no
|
||||||
|
// calendar access at all. Remember the rejection and go quiet instead
|
||||||
|
// of re-probing - and re-logging - on every range change.
|
||||||
|
const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType;
|
||||||
|
const denied = type === 'forbidden' || type === 'accountNotFound' ||
|
||||||
|
/not have access/i.test(error instanceof Error ? error.message : '');
|
||||||
|
if (targetAccountId && denied) {
|
||||||
|
this.calendarAccessDenied.add(targetAccountId);
|
||||||
|
debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
console.error('Failed to query calendar events:', error);
|
console.error('Failed to query calendar events:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user