diff --git a/lib/__tests__/jmap-file-capability.test.ts b/lib/__tests__/jmap-file-capability.test.ts new file mode 100644 index 00000000..0871a13c --- /dev/null +++ b/lib/__tests__/jmap-file-capability.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// Session where the SERVER advertises FileNode (session.capabilities) but the +// per-account accountCapabilities can independently include or omit it. This +// models the #563 scenario: a Stalwart role that revokes jmap-file-node-* +// permissions drops the capability from the account while the server still +// advertises it globally. +function makeSession(accountCapabilities: Record, isPersonal = true) { + return { + capabilities: { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:filenode': {}, + }, + 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): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(accountCapabilities, isPersonal))); + const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + return client; +} + +describe('JMAPClient.supportsFiles (#563 - account-scoped capability)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns true when the account advertises the filenode capability', async () => { + const client = await connect({ 'urn:ietf:params:jmap:filenode': {} }); + expect(client.supportsFiles()).toBe(true); + }); + + it('returns false when the server advertises filenode but the account does not (#563)', async () => { + // The revoked-permission case: server-wide capability present, account omits it. + const client = await connect({ 'urn:ietf:params:jmap:mail': {} }); + expect(client.supportsFiles()).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.supportsFiles()).toBe(true); + }); + + it('probeFileNodeSupport does not probe (no network call) when the account is explicitly denied', async () => { + const client = await connect({ 'urn:ietf:params:jmap:mail': {} }); + fetchSpy.mockClear(); + await expect(client.probeFileNodeSupport()).resolves.toBe(false); + // Explicit per-account denial must short-circuit before any FileNode/query probe. + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 68c4ce1a..5a7d4223 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -40,7 +40,7 @@ export interface IJMAPClient { supportsContacts(): boolean; supportsCalendars(): boolean; supportsSieve(): boolean; - supportsFiles(): boolean; + supportsFiles(accountId?: string): boolean; // ── Push / state ────────────────────────────────────────────── setupPushNotifications(): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 39e9110b..05bf6505 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5209,14 +5209,30 @@ export class JMAPClient implements IJMAPClient { // ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ─── - supportsFiles(): boolean { - return this.hasCapability("urn:ietf:params:jmap:filenode"); + supportsFiles(accountId?: string): boolean { + // Gate on the ACCOUNT capability, not the server-wide session capability. + // A server can advertise urn:ietf:params:jmap:filenode while a specific + // account has its jmap-file-node-* permissions revoked, in which case the + // capability is absent from that account's accountCapabilities and every + // FileNode action fails with an authorization error (#563). Mirror + // getFilesCapableAccountIds(): non-personal (shared/group) accounts don't + // always advertise per-account, so treat those as capable. + const id = accountId || this.accountId; + const account = this.accounts[id]; + if (!account) return false; + return !!account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal; } async probeFileNodeSupport(): Promise { // Some servers support FileNode without advertising a specific capability. // Try a minimal FileNode/query to detect support at runtime. if (this.supportsFiles()) return true; + // If the server advertises FileNode server-wide but this account's + // accountCapabilities omits it, that's an explicit per-account denial (#563) + // - don't probe (the probe would only confirm the revoked account can't use + // it, or worse mislead). Only fall through for servers that don't advertise + // the capability at all. + if (this.hasCapability("urn:ietf:params:jmap:filenode")) return false; if (!this.apiUrl) return false; try { const accountId = this.getFilesAccountId();