Files
webmail/lib/error-reporting/__tests__/client.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

121 lines
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
captureException, configureErrorReporting, recordJmapCall, recordNavigation, reportJmapFailure,
resetErrorReportingForTests,
} from '../client';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
const SUBJECT = 'Merger plans for Acme Q3';
const ADDRESS = 'ceo.private@acme-corp.example';
const FOLDER = 'Acquisition Talks';
function errorWithStack(message: string): Error {
const error = new TypeError(message);
error.stack = `TypeError: ${message}\n at Ab (https://mail.example.org/_next/static/chunks/1.js:2:30)\n at https://mail.example.org/_next/static/chunks/2.js:4:5`;
return error;
}
let fetchSpy: ReturnType<typeof vi.fn>;
function calls(): Array<{ url: string; init: RequestInit }> {
return fetchSpy.mock.calls.map(([url, init]) => ({ url: String(url), init: init as RequestInit }));
}
beforeEach(() => {
resetErrorReportingForTests();
sessionStorage.clear();
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('browser error reporting', () => {
it('sends nothing while reporting is off', () => {
configureErrorReporting(null);
captureException(errorWithStack('boom'));
configureErrorReporting({ dsn: '', sampleRate: 1, environment: 'production', collect: false });
captureException(errorWithStack('boom again'));
expect(fetchSpy).not.toHaveBeenCalled();
});
it('holds reports until the config arrives, then sends them', () => {
captureException(errorWithStack('early'));
expect(fetchSpy).not.toHaveBeenCalled();
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
expect(calls().map((c) => c.url)).toEqual(['https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7']);
});
it('sends to the DSN without cookies or referrer, and with no mail content anywhere', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'staging', collect: true });
window.history.pushState({}, '', `/en/mail/folder/${encodeURIComponent(FOLDER)}/Mabc`);
recordNavigation(window.location.pathname);
recordJmapCall(['Email/query', 'Email/get']);
captureException(errorWithStack(`Failed to open "${SUBJECT}" from ${ADDRESS} at https://mail.example.org/download?name=${encodeURIComponent(SUBJECT)}`));
const [dsnCall, ownCall] = calls();
expect(dsnCall.init.credentials).toBe('omit');
expect(dsnCall.init.referrerPolicy).toBe('no-referrer');
expect(ownCall.url).toBe('/api/errors');
for (const { init } of [dsnCall, ownCall]) {
const body = String(init.body);
for (const planted of [SUBJECT, ADDRESS, FOLDER, encodeURIComponent(FOLDER), 'Merger', 'acme-corp', 'download?name']) {
expect(body).not.toContain(planted);
}
}
const event = JSON.parse(String(dsnCall.init.body).trim().split('\n')[2]);
expect(event.exception.values[0].value).toBe('Failed to open "…" from [email] at [url]');
expect(event.environment).toBe('staging');
expect(event.breadcrumbs.values.map((b: { message: string }) => b.message)).toEqual(['/mail/…', 'Email/query, Email/get']);
expect(event.tags.route).toBe('/mail/…');
});
it('sends each fingerprint once per session', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
captureException(errorWithStack('same'));
captureException(errorWithStack('same'));
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('stops at ten reports a minute', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
for (let i = 0; i < 15; i++) {
const error = new Error(`e${i}`);
error.stack = `Error\n at https://m.example/_next/static/chunks/${i}.js:1:1`;
captureException(error);
}
expect(fetchSpy).toHaveBeenCalledTimes(10);
});
it('applies the sample rate to the DSN only', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
configureErrorReporting({ dsn: DSN, sampleRate: 0.1, environment: 'production', collect: true });
captureException(errorWithStack('sampled out'));
expect(calls().map((c) => c.url)).toEqual(['/api/errors']);
});
it('ignores errors that come only from extensions or other scripts', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
const error = new Error('from an extension');
error.stack = 'Error\n at chrome-extension://abc/content.js:1:1';
captureException(error);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('reports a failed JMAP request by status or method and type, never the answer', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: true });
reportJmapFailure({ status: 503 });
reportJmapFailure({ method: 'Email/set', type: 'overQuota' });
reportJmapFailure({ method: SUBJECT, type: ADDRESS });
const own = calls().filter((c) => c.url === '/api/errors').map((c) => JSON.parse(String(c.init.body)));
expect(own.map((r) => [r.name, r.code])).toEqual([
['JmapRequestError', 'http:503'],
['JmapMethodError', 'Email/set: overQuota'],
['JmapMethodError', 'Unknown/method: unknown'],
]);
});
});