mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
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
121 lines
5.4 KiB
TypeScript
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'],
|
|
]);
|
|
});
|
|
});
|