mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +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
59 lines
2.6 KiB
TypeScript
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' }]);
|
|
});
|
|
});
|