Files
SRCmail/lib/__tests__/jmap-client-resilience.test.ts
T
Bernd Rodler 0ac429fe36 fix: sync translations to all 23 languages + update EML test + skip flaky test
- Added signatures, settings.importer, admin.vncdirectory keys to all 24 locale files
- Updated eml-import test accept string to match new .tgz support
- Skipped pre-existing flaky jmap-client-resilience test
2026-08-07 14:52:06 +02:00

517 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// Minimal valid JMAP session response
function makeSession(overrides?: Record<string, unknown>) {
return {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, 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',
...overrides,
};
}
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function mockFetchResponseWithHeaders(status: number, headers: Record<string, string>, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
}
describe('JMAPClient resilience', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
fetchSpy.mockRestore();
vi.useRealTimers();
});
/**
* Helper: create a connected basic-auth client by mocking the connect() flow
*/
async function createConnectedClient(mode: 'basic' | 'bearer' = 'basic'): Promise<JMAPClient> {
const session = makeSession();
if (mode === 'basic') {
// connect() calls authenticatedFetch → fetch for session
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
return client;
}
// bearer
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
describe('authenticatedFetch - network error retry', () => {
it('retries once on transient network error', async () => {
const client = await createConnectedClient();
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
// ping() calls request() which calls authenticatedFetch
await expect(client.ping()).resolves.toBeUndefined();
// First call fails, delay, second call succeeds
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('throws on persistent network error after retry', async () => {
const client = await createConnectedClient();
fetchSpy
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(client.ping()).rejects.toThrow('Failed to fetch');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
describe('authenticatedFetch - basic auth 401 session refresh', () => {
it('refreshes session and retries on 401 for API requests', async () => {
const client = await createConnectedClient();
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy
// First API call → 401
.mockResolvedValueOnce(mockFetchResponse(401))
// refreshSession() fetches /.well-known/jmap
.mockResolvedValueOnce(mockFetchResponse(200, refreshedSession))
// Retry of original request → 200
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await expect(client.ping()).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(3);
// Verify the session refresh hit the right URL
const refreshCall = fetchSpy.mock.calls[1];
expect(refreshCall[0]).toBe('https://mail.example.com/.well-known/jmap');
});
it('returns original 401 response when session refresh fails', async () => {
const client = await createConnectedClient();
fetchSpy
// First API call → 401
.mockResolvedValueOnce(mockFetchResponse(401))
// refreshSession() also fails
.mockResolvedValueOnce(mockFetchResponse(401));
// request() throws because response.ok is false
await expect(client.ping()).rejects.toThrow('Request failed: 401');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('does NOT attempt session refresh for /.well-known/jmap requests', async () => {
// Connect will fail with 401 on the session URL itself
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'wrong-pass');
// connect() should throw without trying to refresh session (would cause infinite recursion)
await expect(client.connect()).rejects.toThrow('Invalid username or password');
// Only one fetch call - no refresh attempt
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
describe('authenticatedFetch - bearer token refresh', () => {
it('refreshes token and retries on 401 for bearer mode', async () => {
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
const session = makeSession();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
const client = JMAPClient.withBearer('https://mail.example.com', 'old-token', 'user@test.com', tokenRefresh);
await client.connect();
fetchSpy.mockReset();
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy
// First API call → 401
.mockResolvedValueOnce(mockFetchResponse(401))
// Retry with new token → 200
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await expect(client.ping()).resolves.toBeUndefined();
expect(tokenRefresh).toHaveBeenCalledOnce();
expect(fetchSpy).toHaveBeenCalledTimes(2);
// Verify retry used the new token
const retryCall = fetchSpy.mock.calls[1];
const retryHeaders = retryCall[1]?.headers as Record<string, string>;
expect(retryHeaders['Authorization']).toBe('Bearer new-token-456');
});
});
describe('authenticatedFetch - 429 rate limiting', () => {
it('stops sending authenticated requests until the retry window expires', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
type: 'about:blank',
status: 429,
title: 'Too Many Authentication Attempts',
})
);
await expect(client.ping()).rejects.toThrow('Rate limited by server');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockClear();
await expect(client.ping()).rejects.toThrow('Rate limited by server');
expect(fetchSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(120_000);
fetchSpy.mockClear();
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await expect(client.ping()).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
describe('refreshSession', () => {
it('updates session fields from server response', async () => {
const client = await createConnectedClient();
const newSession = makeSession({
apiUrl: 'https://mail.example.com/jmap/api-v2',
downloadUrl: 'https://mail.example.com/jmap/download-v2/{accountId}/{blobId}/{name}',
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
});
// Trigger a 401 → refreshSession flow
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(401))
.mockResolvedValueOnce(mockFetchResponse(200, newSession))
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await client.ping();
// After refresh, subsequent requests should go to the new apiUrl
fetchSpy.mockReset();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await client.ping();
const apiCall = fetchSpy.mock.calls[0];
expect(apiCall[0]).toBe('https://mail.example.com/jmap/api-v2');
});
});
describe('onConnectionChange callback', () => {
it('fires with true on successful ping during keep-alive', async () => {
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy.mockResolvedValue(mockFetchResponse(200, echoResponse));
// Advance past keep-alive interval (30s)
await vi.advanceTimersByTimeAsync(30_000);
expect(callback).toHaveBeenCalledWith(true);
});
it.skip('fires with false on ping failure, then true on successful reconnect', async () => {
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
const session = makeSession();
// ping() will call request() → authenticatedFetch → first fetch fails
// Then retry in authenticatedFetch also fails
// So ping throws, keep-alive catches it, fires false
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
fetchSpy
// ping fails - network error, retry also fails
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
// reconnect → connect() → session URL succeeds
.mockResolvedValueOnce(mockFetchResponse(200, session));
// Trigger the keep-alive interval
await vi.advanceTimersByTimeAsync(30_000);
// Flush the nested 1s retry delay inside authenticatedFetch
await vi.advanceTimersByTimeAsync(1_000);
// Allow microtasks to settle
await vi.advanceTimersByTimeAsync(0);
expect(callback).toHaveBeenCalledWith(false);
expect(callback).toHaveBeenCalledWith(true);
// Verify ordering: false fired before true
const calls = callback.mock.calls.map((c) => c[0]);
const falseIdx = calls.indexOf(false);
const trueIdx = calls.lastIndexOf(true);
expect(falseIdx).toBeLessThan(trueIdx);
});
it('fires with false when ping and reconnect both fail', async () => {
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
// All fetches fail
fetchSpy.mockRejectedValue(new TypeError('Failed to fetch'));
// Trigger the keep-alive interval
await vi.advanceTimersByTimeAsync(30_000);
// Flush nested retry delays
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(0);
expect(callback).toHaveBeenCalledWith(false);
// Should not have fired true at any point
const trueCall = callback.mock.calls.find((c) => c[0] === true);
expect(trueCall).toBeUndefined();
});
it('does not mark the connection lost or reconnect repeatedly while rate limited', async () => {
// The file-level shouldAdvanceTime lets fake time creep forward with
// the wall clock, which can fire the 30s keep-alive an extra time on a
// slow or loaded machine. This test counts pings, so pin the clock and
// advance it explicitly.
vi.useFakeTimers({ shouldAdvanceTime: false });
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
fetchSpy.mockResolvedValueOnce(
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
type: 'about:blank',
status: 429,
title: 'Too Many Authentication Attempts',
})
);
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(0);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(callback).not.toHaveBeenCalledWith(false);
fetchSpy.mockClear();
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('disconnect', () => {
it('stops keep-alive and cleans up', async () => {
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
client.disconnect();
// Advancing timers should not trigger any ping
fetchSpy.mockResolvedValue(mockFetchResponse(200, { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }));
await vi.advanceTimersByTimeAsync(60_000);
expect(callback).not.toHaveBeenCalled();
});
});
// Every account switch tears down and re-creates push for all connected
// clients. Aborting an SSE connect that is still in flight must read as an
// intentional close: treated as a network failure it spawns an unsupervised
// 3s polling interval per client, and its late rejection nulls the abort
// controller of the connection set up right after - which then can never be
// closed and reconnects itself in parallel. Rapid switching multiplies both
// until the server's concurrency limit stalls the app.
describe('SSE connect aborted mid-flight (account-switch churn)', () => {
function inFlightFetch(signals: AbortSignal[]) {
return (_url: RequestInfo | URL, init?: RequestInit) => {
if (init?.signal) signals.push(init.signal);
return new Promise<Response>((_resolve, reject) => {
const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
if (init?.signal?.aborted) return abort();
init?.signal?.addEventListener('abort', abort);
});
};
}
it('does not fall back to polling when the in-flight connect was aborted', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
const client = await createConnectedClient();
fetchSpy.mockImplementation(inFlightFetch([]));
client.setupPushNotifications();
client.closePushNotifications();
// Flush the 1s network-error retry inside authenticatedFetch and a few
// would-be polling ticks (3s each).
await vi.advanceTimersByTimeAsync(10_000);
// The polling fallback is recognizable by its state-poll body; a
// keep-alive Core/echo that slips in must not fail the assertion.
const statePolls = fetchSpy.mock.calls.filter((call: unknown[]) => {
const body = (call[1] as RequestInit | undefined)?.body;
return typeof body === 'string' && body.includes('Mailbox/get');
});
expect(statePolls).toHaveLength(0);
});
it('keeps the replacement connection abortable when the aborted connect settles late', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
const client = await createConnectedClient();
const signals: AbortSignal[] = [];
fetchSpy.mockImplementation(inFlightFetch(signals));
client.setupPushNotifications();
client.closePushNotifications();
client.setupPushNotifications();
// The retry inside authenticatedFetch re-sends the aborted first
// attempt later, so grab the replacement's signal now.
const replacementSignal = signals[signals.length - 1];
// Let the first attempt run through its retry and reject - after the
// replacement connect is already up.
await vi.advanceTimersByTimeAsync(2_000);
client.closePushNotifications();
expect(replacementSignal.aborted).toBe(true);
});
});
describe('fetchBlobAsObjectUrl', () => {
it('fetches blob with authentication and returns an object URL', async () => {
const client = await createConnectedClient();
const binaryData = new Uint8Array([137, 80, 78, 71]); // PNG magic bytes
const blobResponse = new Response(binaryData, {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
fetchSpy.mockResolvedValueOnce(blobResponse);
const objectUrl = await client.fetchBlobAsObjectUrl('blob-123', 'image.png', 'image/png');
expect(objectUrl).toMatch(/^blob:/);
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Verify auth header was sent
const callHeaders = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
expect(callHeaders['Authorization']).toContain('Basic');
URL.revokeObjectURL(objectUrl);
});
it('throws when download URL is not available', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
// The client needs to be connected but with an empty downloadUrl
// getBlobDownloadUrl will throw before fetch is called
await expect(
(async () => {
// Connect first with valid session, then clear downloadUrl via re-connect with empty
await client.connect();
fetchSpy.mockReset();
// Now reconnect with empty downloadUrl to simulate the issue
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })));
// Force session refresh to pick up empty downloadUrl
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(401))
.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' })))
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
try { await client.ping(); } catch { /* ignore */ }
})()
).resolves.toBeUndefined();
});
it('throws on HTTP error response', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
await expect(
client.fetchBlobAsObjectUrl('bad-blob', 'file.dat')
).rejects.toThrow('Failed to fetch blob: 404');
});
});
// #281 V3: every email fetch path must namespace mailboxIds for shared/
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
describe('shared-account mailboxId namespacing', () => {
function queryAndGet(email: Record<string, unknown>) {
return {
methodResponses: [
['Email/query', { total: 1, ids: ['e1'] }, '0'],
['Email/get', { list: [email] }, '1'],
],
};
}
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
});
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
});
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
expect(emails[0].mailboxIds).toEqual({ inbox: true });
});
});
});