mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
Marking read, starring, tagging, emptying a folder and cancelling a scheduled send only checked that the request came back. A `/set` the server refused - `notUpdated`, `notDestroyed` or a method-level `error` - resolved as if it had worked: the list showed the new state, and a cancel the server rejected looked cancelled while the message still went out. These writes now go through one check that throws on any of those, so the store keeps the old state and shows the server's reason.
931 lines
39 KiB
TypeScript
931 lines
39 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient, RequestTimeoutError, isReplaySafeRequest } from '../jmap/client';
|
|
|
|
/**
|
|
* A connection that accepts the request and then goes silent: the promise never
|
|
* settles on its own, exactly like `fetch` over a socket the network has torn
|
|
* down underneath it. Only an abort ends it - which is what the deadline does.
|
|
*/
|
|
function stalledFetch() {
|
|
return (_url: string, init?: RequestInit) =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => {
|
|
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A call no test queued a response for. It must never fall through to the
|
|
* real `fetch`: a request to mail.example.com fails on the network's own
|
|
* schedule, and the client's 1s retry timer then lands on whichever fake clock
|
|
* is installed by the time the failure comes back - i.e. inside a later test,
|
|
* where the replayed request shows up as an extra `fetch` call.
|
|
*/
|
|
function unmockedFetch(url: RequestInfo | URL): Promise<Response> {
|
|
return Promise.reject(new Error(`Unmocked fetch: ${String(url)}`));
|
|
}
|
|
|
|
describe('JMAPClient resilience', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
// Every client a test connected. Their keep-alive intervals and rate-limit
|
|
// timers are stopped in afterEach so nothing from one test can fire inside
|
|
// the next one's fake clock.
|
|
const liveClients: JMAPClient[] = [];
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(unmockedFetch);
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const client of liveClients.splice(0)) client.disconnect();
|
|
fetchSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Drop queued one-shot responses and call history; keep the unmocked-fetch guard. */
|
|
function resetFetch() {
|
|
fetchSpy.mockReset();
|
|
fetchSpy.mockImplementation(unmockedFetch);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
liveClients.push(client);
|
|
resetFetch();
|
|
return client;
|
|
}
|
|
|
|
// bearer
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
|
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
|
await client.connect();
|
|
liveClients.push(client);
|
|
resetFetch();
|
|
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);
|
|
});
|
|
|
|
it('does not replay a request that changes something', async () => {
|
|
// The connection may have failed after the server acted on the batch:
|
|
// replaying an Email/set or an EmailSubmission/set does it twice.
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
|
|
|
await expect(client.deleteEmail('email-1')).rejects.toThrow('Failed to fetch');
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('reading lists when the server fails', () => {
|
|
// An empty answer read as an empty folder: a failed refresh emptied the
|
|
// list, and a failed delta read treated every updated row as gone.
|
|
it('getEmails rejects instead of returning an empty page', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
methodResponses: [['error', { type: 'serverFail', description: 'backend down' }, '0']],
|
|
}));
|
|
await expect(client.getEmails('inbox')).rejects.toThrow('backend down');
|
|
});
|
|
|
|
it('getSomeEmails rejects instead of returning nothing', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
methodResponses: [['error', { type: 'serverFail' }, '0']],
|
|
}));
|
|
await expect(client.getSomeEmails(['e1'])).rejects.toThrow('serverFail');
|
|
});
|
|
});
|
|
|
|
describe('writes the server refuses', () => {
|
|
// HTTP 200 says nothing about the objects: a refused /set used to be
|
|
// shown as done.
|
|
it('markAsRead rejects on notUpdated', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
methodResponses: [['Email/set', { notUpdated: { e1: { type: 'forbidden', description: 'read-only folder' } } }, '0']],
|
|
}));
|
|
await expect(client.markAsRead('e1', true)).rejects.toThrow('read-only folder');
|
|
});
|
|
|
|
it('toggleStar rejects on a method-level error', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
methodResponses: [['error', { type: 'accountReadOnly' }, '0']],
|
|
}));
|
|
await expect(client.toggleStar('e1', true)).rejects.toThrow('accountReadOnly');
|
|
});
|
|
|
|
it('cancelEmailSubmission rejects on a method-level error, so the send is not shown as cancelled', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
methodResponses: [['error', { type: 'invalidArguments' }, '0']],
|
|
}));
|
|
await expect(client.cancelEmailSubmission('s1', 'acc')).rejects.toThrow('invalidArguments');
|
|
});
|
|
});
|
|
|
|
describe('isReplaySafeRequest', () => {
|
|
const batch = (...methods: string[]) => ({
|
|
method: 'POST',
|
|
body: JSON.stringify({ using: [], methodCalls: methods.map((m, i) => [m, {}, String(i)]) }),
|
|
});
|
|
|
|
it('allows GETs, uploads and read-only batches', () => {
|
|
expect(isReplaySafeRequest(undefined)).toBe(true);
|
|
expect(isReplaySafeRequest({ method: 'GET' })).toBe(true);
|
|
expect(isReplaySafeRequest({ method: 'POST', body: new Blob(['x']) })).toBe(true);
|
|
expect(isReplaySafeRequest(batch('Email/query', 'Email/get', 'Mailbox/changes', 'Core/echo'))).toBe(true);
|
|
});
|
|
|
|
it('refuses anything that writes, and bodies it cannot read', () => {
|
|
expect(isReplaySafeRequest(batch('Email/get', 'Email/set'))).toBe(false);
|
|
expect(isReplaySafeRequest(batch('Email/set', 'EmailSubmission/set'))).toBe(false);
|
|
expect(isReplaySafeRequest(batch('Email/copy'))).toBe(false);
|
|
expect(isReplaySafeRequest(batch('Email/import'))).toBe(false);
|
|
expect(isReplaySafeRequest({ method: 'POST', body: 'not json' })).toBe(false);
|
|
expect(isReplaySafeRequest({ method: 'POST', body: JSON.stringify({ methodCalls: [] }) })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('authenticatedFetch - stalled request deadline', () => {
|
|
const downloadCalls = () =>
|
|
fetchSpy.mock.calls.filter(([url]: unknown[]) => String(url).includes('/jmap/download/'));
|
|
|
|
it('rejects a request that never produces response headers', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockImplementation(stalledFetch());
|
|
|
|
const pending = client.ping();
|
|
const assertion = expect(pending).rejects.toBeInstanceOf(RequestTimeoutError);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await assertion;
|
|
});
|
|
|
|
it('does not replay a timed-out request', async () => {
|
|
// The network-error path retries once, but a timeout may mean the server
|
|
// received and acted on the request - replaying an EmailSubmission/set
|
|
// there would send the message twice. Counted on the download URL so the
|
|
// keep-alive ping, which fires against the API URL while the clock runs,
|
|
// stays out of the tally.
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockImplementation(stalledFetch());
|
|
|
|
const pending = client.fetchBlob('blob-1', 'big.zip', 'application/zip');
|
|
const assertion = expect(pending).rejects.toBeInstanceOf(RequestTimeoutError);
|
|
await vi.advanceTimersByTimeAsync(300_000);
|
|
await assertion;
|
|
// Well past the 1s the retry path would have waited.
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
expect(downloadCalls()).toHaveLength(1);
|
|
});
|
|
|
|
it('clears the deadline once the response arrives, leaving the body alone', async () => {
|
|
const client = await createConnectedClient();
|
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
|
let capturedSignal: AbortSignal | undefined;
|
|
|
|
fetchSpy.mockImplementation((_url: string, init?: RequestInit) => {
|
|
capturedSignal = init?.signal ?? undefined;
|
|
return Promise.resolve(mockFetchResponse(200, echoResponse));
|
|
});
|
|
|
|
await expect(client.ping()).resolves.toBeUndefined();
|
|
|
|
// Long after the deadline would have fired, a streaming body handed back
|
|
// to the caller (SSE) must still be readable.
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
|
expect(capturedSignal?.aborted).toBe(false);
|
|
});
|
|
|
|
it('gives blob transfers a longer budget than ordinary requests', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockImplementation(stalledFetch());
|
|
|
|
let outcome: unknown;
|
|
const pending = client
|
|
.fetchBlob('blob-1', 'big.zip', 'application/zip')
|
|
.catch((error) => { outcome = error; });
|
|
|
|
// A large attachment on a slow uplink is legitimately still moving well
|
|
// past the deadline that applies to an ordinary API call.
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(outcome).toBeUndefined();
|
|
|
|
await vi.advanceTimersByTimeAsync(300_000);
|
|
await pending;
|
|
expect(outcome).toBeInstanceOf(RequestTimeoutError);
|
|
});
|
|
});
|
|
|
|
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();
|
|
liveClients.push(client);
|
|
resetFetch();
|
|
|
|
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();
|
|
|
|
// The keep-alive ping goes out again the instant the window closes, which
|
|
// is inside this advance - serve it as well, or it would be an unmocked
|
|
// call (see unmockedFetch).
|
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
|
fetchSpy.mockImplementation(() => Promise.resolve(mockFetchResponse(200, echoResponse)));
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
|
fetchSpy.mockClear();
|
|
|
|
await expect(client.ping()).resolves.toBeUndefined();
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('session URL rewriting', () => {
|
|
async function connectWith(session: Record<string, unknown>, serverUrl = 'https://mail.example.com') {
|
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(session)));
|
|
const client = new JMAPClient(serverUrl, 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
liveClients.push(client);
|
|
resetFetch();
|
|
return client;
|
|
}
|
|
|
|
async function pingUrl(client: JMAPClient): Promise<string> {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
mockFetchResponse(200, { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }),
|
|
);
|
|
await client.ping();
|
|
return String(fetchSpy.mock.calls[0][0]);
|
|
}
|
|
|
|
it.each([
|
|
['absolute-path relative', '/jmap/api'],
|
|
['bare relative', 'jmap/api'],
|
|
['cross-origin absolute', 'https://other.example.com/jmap/api'],
|
|
['network-path reference', '//other.example.com/jmap/api'],
|
|
])('anchors a %s apiUrl at the server origin', async (_form, apiUrl) => {
|
|
const client = await connectWith({ apiUrl });
|
|
expect(await pingUrl(client)).toBe('https://mail.example.com/jmap/api');
|
|
});
|
|
|
|
it('anchors a relative apiUrl at the origin even when serverUrl has a path', async () => {
|
|
const client = await connectWith({ apiUrl: 'jmap/api' }, 'https://mail.example.com/proxy');
|
|
expect(await pingUrl(client)).toBe('https://mail.example.com/jmap/api');
|
|
});
|
|
|
|
it.each([
|
|
['same-origin', 'https://mail.example.com'],
|
|
['cross-origin', 'https://other.example.com'],
|
|
])('keeps URI Template placeholders literal in a %s downloadUrl', async (_o, host) => {
|
|
const client = await connectWith({
|
|
downloadUrl: `${host}/download/{accountId}/{blobId}/{name}?accept={type}`,
|
|
});
|
|
expect(client.getBlobDownloadUrl('blob1', 'file.txt', 'text/plain', 'acct-1')).toBe(
|
|
'https://mail.example.com/download/acct-1/blob1/file.txt?accept=text%2Fplain',
|
|
);
|
|
});
|
|
|
|
it('leaves an empty downloadUrl falsy so the unavailable guard still fires', async () => {
|
|
const client = await connectWith({ downloadUrl: '' });
|
|
expect(() => client.getBlobDownloadUrl('blob1')).toThrow('Download URL not available');
|
|
});
|
|
});
|
|
|
|
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
|
|
resetFetch();
|
|
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']] };
|
|
// A Response body is single-use, so one shared instance breaks as soon
|
|
// as anything fetches more than once in the window - serve a fresh one
|
|
// per call like the other tests in this file.
|
|
fetchSpy.mockImplementation(() => Promise.resolve(mockFetchResponse(200, echoResponse)));
|
|
|
|
// Advance past keep-alive interval (30s)
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
|
|
expect(callback).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('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);
|
|
});
|
|
|
|
// #781: setupPushNotifications is re-run for every connected client on
|
|
// fairly frequent effect deps changes. Without an already-active guard a
|
|
// second connect stacks a new SSE stream while the previous one keeps its
|
|
// HTTP/1.1 socket open forever (readSSEStream only unwinds on abort/done),
|
|
// starving normal JMAP POSTs. Opening a new stream must abort the old one.
|
|
it('aborts the previous SSE stream when push is set up again (no orphan)', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
const client = await createConnectedClient();
|
|
const signals: AbortSignal[] = [];
|
|
fetchSpy.mockImplementation(inFlightFetch(signals));
|
|
|
|
client.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0); // let connect #1 register its signal
|
|
client.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0); // connect #2 registers + guard aborts #1
|
|
|
|
// The first stream's signal is aborted, and exactly one stream stays live
|
|
// - no accumulation of abandoned SSE connections.
|
|
expect(signals[0].aborted).toBe(true);
|
|
expect(signals.filter((s) => !s.aborted)).toHaveLength(1);
|
|
|
|
client.closePushNotifications();
|
|
});
|
|
});
|
|
|
|
// #702: one logged-in account = one JMAPClient = one SSE stream held open as
|
|
// a long-lived fetch, i.e. one pinned HTTP/1.1 socket. Browsers allow 6 per
|
|
// host, so from the sixth login on no socket is left for ordinary JMAP
|
|
// POSTs - sends grey out and never finish, loads and moves take minutes.
|
|
// The client must cap concurrent streams per tab and slow-poll the rest.
|
|
describe('SSE stream budget across logins (#702)', () => {
|
|
const isSSE = (init?: RequestInit) =>
|
|
(init?.headers as Record<string, string> | undefined)?.['Accept'] === 'text/event-stream';
|
|
|
|
function trackingFetch(sse: AbortSignal[]) {
|
|
return (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
if (isSSE(init)) {
|
|
if (init?.signal) sse.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);
|
|
});
|
|
}
|
|
return Promise.resolve(mockFetchResponse(200, {
|
|
methodResponses: [
|
|
['Mailbox/get', { state: 'm1', list: [] }, 'mbx:acct-1'],
|
|
['Email/get', { state: 'e1', list: [] }, 'eml:acct-1'],
|
|
],
|
|
}));
|
|
};
|
|
}
|
|
|
|
const statePollCount = () =>
|
|
fetchSpy.mock.calls.filter((call: unknown[]) => {
|
|
const body = (call[1] as RequestInit | undefined)?.body;
|
|
return typeof body === 'string' && body.includes('Mailbox/get');
|
|
}).length;
|
|
|
|
async function createClients(n: number): Promise<JMAPClient[]> {
|
|
const clients: JMAPClient[] = [];
|
|
for (let i = 0; i < n; i++) clients.push(await createConnectedClient());
|
|
return clients;
|
|
}
|
|
|
|
it('never holds more than MAX_SSE_STREAMS streams open, however many logins there are', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
const clients = await createClients(7);
|
|
const sse: AbortSignal[] = [];
|
|
fetchSpy.mockImplementation(trackingFetch(sse));
|
|
|
|
for (const c of clients) c.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(sse.filter((s) => !s.aborted)).toHaveLength(JMAPClient.MAX_SSE_STREAMS);
|
|
expect(JMAPClient.activeSSEStreamCount()).toBe(JMAPClient.MAX_SSE_STREAMS);
|
|
// Setup order decides who gets a slot - the caller puts the active login first.
|
|
expect(clients[0].hasSSEStream()).toBe(true);
|
|
expect(clients[1].hasSSEStream()).toBe(true);
|
|
expect(clients[6].hasSSEStream()).toBe(false);
|
|
|
|
for (const c of clients) c.closePushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(JMAPClient.activeSSEStreamCount()).toBe(0);
|
|
});
|
|
|
|
it('slow-polls the logins that did not get a stream so their counters still refresh', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
const clients = await createClients(3);
|
|
const sse: AbortSignal[] = [];
|
|
fetchSpy.mockImplementation(trackingFetch(sse));
|
|
|
|
for (const c of clients) c.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
// The budget-denied client primes its baseline immediately...
|
|
expect(statePollCount()).toBe(1);
|
|
|
|
fetchSpy.mockClear();
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
// ...and polls on the slow 20s cadence, not the 3s outage fallback.
|
|
expect(statePollCount()).toBe(1);
|
|
|
|
for (const c of clients) c.closePushNotifications();
|
|
});
|
|
|
|
it('promotes a waiting login into the slot a closed stream releases', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
const clients = await createClients(3);
|
|
const sse: AbortSignal[] = [];
|
|
fetchSpy.mockImplementation(trackingFetch(sse));
|
|
|
|
for (const c of clients) c.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(clients[2].hasSSEStream()).toBe(false);
|
|
|
|
clients[0].closePushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(clients[0].hasSSEStream()).toBe(false);
|
|
expect(clients[2].hasSSEStream()).toBe(true);
|
|
expect(sse.filter((s) => !s.aborted)).toHaveLength(JMAPClient.MAX_SSE_STREAMS);
|
|
|
|
for (const c of clients) c.closePushNotifications();
|
|
});
|
|
|
|
it('does not promote a waiter that was closed in the same teardown (account-switch churn)', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
const clients = await createClients(3);
|
|
const sse: AbortSignal[] = [];
|
|
fetchSpy.mockImplementation(trackingFetch(sse));
|
|
|
|
for (const c of clients) c.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// The push effect closes every client and re-runs setup synchronously.
|
|
for (const c of clients) c.closePushNotifications();
|
|
for (const c of [clients[2], clients[1], clients[0]]) c.setupPushNotifications();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(JMAPClient.activeSSEStreamCount()).toBe(JMAPClient.MAX_SSE_STREAMS);
|
|
expect(clients[2].hasSSEStream()).toBe(true);
|
|
expect(clients[1].hasSSEStream()).toBe(true);
|
|
expect(clients[0].hasSSEStream()).toBe(false);
|
|
expect(sse.filter((s) => !s.aborted)).toHaveLength(JMAPClient.MAX_SSE_STREAMS);
|
|
|
|
for (const c of clients) c.closePushNotifications();
|
|
});
|
|
});
|
|
|
|
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('never hands back an object URL typed as a script-bearing document', async () => {
|
|
const client = await createConnectedClient();
|
|
fetchSpy.mockResolvedValueOnce(new Response('<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'image/svg+xml' },
|
|
}));
|
|
const created: Blob[] = [];
|
|
const spy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
|
created.push(blob as Blob);
|
|
return 'blob:test';
|
|
});
|
|
|
|
await client.fetchBlobAsObjectUrl('blob-svg', 'logo.svg', 'image/svg+xml');
|
|
|
|
expect(created[0].type).toBe('application/octet-stream');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
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();
|
|
liveClients.push(client);
|
|
resetFetch();
|
|
// 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 });
|
|
});
|
|
});
|
|
});
|