Files
webmail/lib/__tests__/jmap-error-reporting.test.ts
T
Linus Rath cba1109444 feat: report browser and server errors to an error tracker the admin sets
Admins had logs and nothing else: client errors only reached the
browser console. An admin can now set a Sentry-protocol address (DSN)
for Bugsink, GlitchTip or Sentry under System > Monitoring, or with
`ERROR_REPORTING_DSN`, `ERROR_REPORTING_DSN_FILE`,
`ERROR_REPORTING_SAMPLE_RATE` and `ERROR_REPORTING_ENVIRONMENT`.
Bulwark Lite reads the same from `errorReporting` in policy.json.
Everything stays off while no address is set.

- browsers report errors from the error boundaries, window.onerror,
  unhandled rejections and failed JMAP requests (method and error type
  only), with route and JMAP method breadcrumbs and the chunks' debug IDs
- the server reports what Next hands to onRequestError and what routes
  log with logger.error
- every report is scrubbed first: quoted text, addresses, URLs, file
  names, tokens and the rest after the first line are removed, and the
  server scrubs what browsers post to /api/errors again
- reports are deduplicated and rate-limited per session and process
- errors are counted per day for "errors today" on the admin page
- startup messages and audit entries go through the logger, so
  `LOG_FORMAT=json` covers them
2026-11-01 02:50:19 +01:00

59 lines
2.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const reported = vi.hoisted(() => ({ calls: [] as string[][], failures: [] as unknown[] }));
vi.mock('@/lib/error-reporting/client', () => ({
recordJmapCall: (methods: string[]) => { reported.calls.push(methods); },
reportJmapFailure: (failure: unknown) => { reported.failures.push(failure); },
}));
import { JMAPClient } from '../jmap/client';
type Call = [string, Record<string, unknown>, string];
const SUBJECT = 'Merger plans for Acme Q3';
function clientAnswering(response: Response) {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, { apiUrl: 'https://jmap.example.com/jmap/', accountId: 'me' });
vi.spyOn(client as unknown as { authenticatedFetch: () => Promise<Response> }, 'authenticatedFetch').mockResolvedValue(response);
const request = (calls: Call[]) => (client as unknown as { request: (c: Call[]) => Promise<unknown> }).request(calls);
return request;
}
beforeEach(() => {
reported.calls = [];
reported.failures = [];
});
describe('JMAP requests and error reporting', () => {
it('keeps the method names of each request as a breadcrumb', async () => {
const request = clientAnswering(new Response(JSON.stringify({ methodResponses: [] })));
await request([['Email/query', { filter: { subject: SUBJECT } }, '0'], ['Email/get', {}, '1']]);
expect(reported.calls).toEqual([['Email/query', 'Email/get']]);
expect(reported.failures).toEqual([]);
});
it('reports a failed request by its status only', async () => {
const request = clientAnswering(new Response(`{"detail":"${SUBJECT}"}`, { status: 503 }));
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
expect(reported.failures).toEqual([{ status: 503 }]);
});
it('does not report an expired token or the concurrency limit', async () => {
const request = clientAnswering(new Response('', { status: 401 }));
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
expect(reported.failures).toEqual([]);
});
it('reports method errors by method and type, but not the ones that only mean "sync again"', async () => {
const request = clientAnswering(new Response(JSON.stringify({
methodResponses: [
['error', { type: 'overQuota', description: SUBJECT }, 'a'],
['error', { type: 'cannotCalculateChanges' }, 'b'],
['Mailbox/get', { list: [] }, 'c'],
],
})));
await request([['Email/set', {}, 'a'], ['Email/changes', {}, 'b'], ['Mailbox/get', {}, 'c']]);
expect(reported.failures).toEqual([{ method: 'Email/set', type: 'overQuota' }]);
});
});