diff --git a/lib/__tests__/jmap-calendar-capability.test.ts b/lib/__tests__/jmap-calendar-capability.test.ts new file mode 100644 index 000000000..21104bd7c --- /dev/null +++ b/lib/__tests__/jmap-calendar-capability.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// The server advertises Calendars globally while the per-account +// accountCapabilities independently includes or omits it. +function makeSession(accountCapabilities: Record, isPersonal = true, serverAdvertises = true) { + return { + capabilities: { + 'urn:ietf:params:jmap:core': {}, + ...(serverAdvertises ? { 'urn:ietf:params:jmap:calendars': {} } : {}), + }, + accounts: { + 'acct-1': { name: 'test', isPersonal, 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: 'https://mail.example.com/jmap/eventsource', + }; +} + +function mockFetchResponse(status: number, body?: unknown): Response { + return new Response(body ? JSON.stringify(body) : null, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +async function connect(accountCapabilities: Record, isPersonal = true, serverAdvertises = true): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(accountCapabilities, isPersonal, serverAdvertises))); + const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + return client; +} + +describe('JMAPClient.supportsCalendars (account-scoped capability)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns true when the account advertises the calendars capability', async () => { + const client = await connect({ 'urn:ietf:params:jmap:calendars': {} }); + expect(client.supportsCalendars()).toBe(true); + }); + + it('returns false when the server advertises calendars but the account does not', async () => { + const client = await connect({ 'urn:ietf:params:jmap:mail': {} }); + expect(client.supportsCalendars()).toBe(false); + }); + + it('treats non-personal (shared/group) accounts as capable even without per-account advertisement', async () => { + const client = await connect({}, /* isPersonal */ false); + expect(client.supportsCalendars()).toBe(true); + }); + + it('returns false for a shared account when the server does not advertise calendars', async () => { + const client = await connect({}, /* isPersonal */ false, /* serverAdvertises */ false); + expect(client.supportsCalendars()).toBe(false); + }); +}); diff --git a/lib/__tests__/jmap-contact-client.test.ts b/lib/__tests__/jmap-contact-client.test.ts index c0be5fd52..8fb5a6147 100644 --- a/lib/__tests__/jmap-contact-client.test.ts +++ b/lib/__tests__/jmap-contact-client.test.ts @@ -48,22 +48,39 @@ describe('JMAPClient contact methods', () => { }); describe('supportsContacts', () => { - it('should return true when contacts capability exists', () => { + function withAccountCapabilities( + client: JMAPClient, + accountCapabilities: Record, + isPersonal = true, + ) { + Object.assign(client, { + capabilities: { 'urn:ietf:params:jmap:contacts': {} }, + accounts: { 'account-1': { name: 'test', isPersonal, accountCapabilities } }, + }); + } + + it('should return true when the account advertises the contacts capability', () => { const client = createClient(); - Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:contacts': {} } }); + withAccountCapabilities(client, { 'urn:ietf:params:jmap:contacts': {} }); expect(client.supportsContacts()).toBe(true); }); - it('should return false when contacts capability is missing', () => { + it('should return false when the server advertises contacts but the account does not', () => { const client = createClient(); - Object.assign(client, { capabilities: {} }); + withAccountCapabilities(client, { 'urn:ietf:params:jmap:mail': {} }); expect(client.supportsContacts()).toBe(false); }); - it('should throw when capabilities is undefined', () => { + it('should treat non-personal (shared/group) accounts as capable', () => { const client = createClient(); - Object.assign(client, { capabilities: undefined }); - expect(() => client.supportsContacts()).toThrow(); + withAccountCapabilities(client, {}, /* isPersonal */ false); + expect(client.supportsContacts()).toBe(true); + }); + + it('should return false when no session has been loaded', () => { + const client = createClient(); + Object.assign(client, { capabilities: undefined, accounts: {} }); + expect(client.supportsContacts()).toBe(false); }); }); diff --git a/lib/__tests__/jmap-contacts-capability.test.ts b/lib/__tests__/jmap-contacts-capability.test.ts new file mode 100644 index 000000000..b7af10bee --- /dev/null +++ b/lib/__tests__/jmap-contacts-capability.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// The server advertises Contacts globally while the per-account +// accountCapabilities independently includes or omits it. +function makeSession(accountCapabilities: Record, isPersonal = true, serverAdvertises = true) { + return { + capabilities: { + 'urn:ietf:params:jmap:core': {}, + ...(serverAdvertises ? { 'urn:ietf:params:jmap:contacts': {} } : {}), + }, + accounts: { + 'acct-1': { name: 'test', isPersonal, 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: 'https://mail.example.com/jmap/eventsource', + }; +} + +function mockFetchResponse(status: number, body?: unknown): Response { + return new Response(body ? JSON.stringify(body) : null, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +async function connect(accountCapabilities: Record, isPersonal = true, serverAdvertises = true): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(accountCapabilities, isPersonal, serverAdvertises))); + const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + return client; +} + +describe('JMAPClient.supportsContacts (account-scoped capability)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns true when the account advertises the contacts capability', async () => { + const client = await connect({ 'urn:ietf:params:jmap:contacts': {} }); + expect(client.supportsContacts()).toBe(true); + }); + + it('returns false when the server advertises contacts but the account does not', async () => { + const client = await connect({ 'urn:ietf:params:jmap:mail': {} }); + expect(client.supportsContacts()).toBe(false); + }); + + it('treats non-personal (shared/group) accounts as capable even without per-account advertisement', async () => { + const client = await connect({}, /* isPersonal */ false); + expect(client.supportsContacts()).toBe(true); + }); + + it('returns false for a shared account when the server does not advertise contacts', async () => { + const client = await connect({}, /* isPersonal */ false, /* serverAdvertises */ false); + expect(client.supportsContacts()).toBe(false); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d000bd872..8fe3f9ce8 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3875,12 +3875,28 @@ export class JMAPClient implements IJMAPClient { return this.hasCapability("urn:ietf:params:jmap:vacationresponse"); } - supportsContacts(): boolean { - return this.hasCapability("urn:ietf:params:jmap:contacts"); + supportsContacts(accountId?: string): boolean { + // Gate on the ACCOUNT capability: a server can advertise contacts while an + // account has its jmap-contact-* / dav-card-* permissions revoked. Shared accounts + // aren't always advertised per-account, so fall back to the server-wide + // capability - accountCapabilities is a subset of it (RFC 8620 s2). + const id = accountId || this.accountId; + const account = this.accounts[id]; + if (!account) return false; + if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"]) return true; + return !account.isPersonal && !!this.capabilities?.["urn:ietf:params:jmap:contacts"]; } - supportsCalendars(): boolean { - return this.hasCapability("urn:ietf:params:jmap:calendars"); + supportsCalendars(accountId?: string): boolean { + // Gate on the ACCOUNT capability: a server can advertise calendars while an + // account has its jmap-calendar-* / dav-cal-* permissions revoked. Shared accounts + // aren't always advertised per-account, so fall back to the server-wide + // capability - accountCapabilities is a subset of it (RFC 8620 s2). + const id = accountId || this.accountId; + const account = this.accounts[id]; + if (!account) return false; + if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"]) return true; + return !account.isPersonal && !!this.capabilities?.["urn:ietf:params:jmap:calendars"]; } supportsSieve(): boolean {