mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +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
102 lines
4.5 KiB
TypeScript
102 lines
4.5 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const config: Record<string, unknown> = {};
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: {
|
|
ensureLoaded: async () => {},
|
|
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
|
},
|
|
}));
|
|
|
|
import { captureLoggedError, captureRequestError, installServerErrorReporting } from '../server';
|
|
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '../aggregate';
|
|
import { logger, setErrorSink } from '@/lib/logger';
|
|
|
|
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
|
const SUBJECT = 'Merger plans for Acme Q3';
|
|
const ADDRESS = 'ceo.private@acme-corp.example';
|
|
|
|
let dir: string;
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-'));
|
|
process.env.TELEMETRY_DATA_DIR = dir;
|
|
for (const key of Object.keys(config)) delete config[key];
|
|
resetAggregateForTests();
|
|
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setErrorSink(null);
|
|
resetAggregateForTests();
|
|
vi.unstubAllGlobals();
|
|
delete process.env.TELEMETRY_DATA_DIR;
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function serverError(message: string): Error {
|
|
const error = new Error(message);
|
|
error.stack = `Error: ${message}\n at handler (/app/.next/server/chunks/77.js:1:900)\n at /app/node_modules/next/dist/server.js:5:6`;
|
|
return error;
|
|
}
|
|
|
|
describe('server error reporting', () => {
|
|
it('does nothing at all without a DSN or other reason to collect', async () => {
|
|
await captureRequestError(serverError('boom'), '/api/admin/config');
|
|
await flushErrors();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(existsSync(path.join(dir, 'errors.json'))).toBe(false);
|
|
});
|
|
|
|
it('sends a request error to the DSN with the route pattern and no mail content', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
config.errorReportingEnvironment = 'beta';
|
|
await captureRequestError(serverError(`Could not file "${SUBJECT}" for ${ADDRESS}`), '/api/wopi/files/[fileId]');
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchSpy.mock.calls[0];
|
|
expect(url).toBe('https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7');
|
|
const body = String((init as RequestInit).body);
|
|
expect(body).not.toContain('Merger');
|
|
expect(body).not.toContain(ADDRESS);
|
|
const event = JSON.parse(body.trim().split('\n')[2]);
|
|
expect(event.platform).toBe('node');
|
|
expect(event.environment).toBe('beta');
|
|
expect(event.tags).toEqual({ runtime: 'server', route: '/api/wopi/files/[fileId]' });
|
|
expect(event.exception.values[0].stacktrace.frames.at(-1).abs_path).toBe('/.next/server/chunks/77.js');
|
|
});
|
|
|
|
it('counts the error per day', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
await captureRequestError(serverError('one'));
|
|
await captureRequestError(serverError('one'));
|
|
await flushErrors();
|
|
const day = (await readErrors())[utcDay()];
|
|
expect(Object.values(day).map((e) => [e.name, e.runtime, e.count])).toEqual([['Error', 'server', 2]]);
|
|
});
|
|
|
|
it('reports logger.error calls with the caller\'s frames once installed', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
await installServerErrorReporting();
|
|
logger.error('telemetry GET error', { error: `failed for ${ADDRESS}` });
|
|
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
|
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
|
|
expect(event.exception.values[0].type).toBe('LoggedError');
|
|
expect(event.exception.values[0].value).toBe('telemetry GET error: failed for [email]');
|
|
});
|
|
|
|
it('drops the logger\'s own frames from a logged error', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
const stack = 'Error\n at log (/app/.next/server/chunks/1.js:1:1)\n at Object.error (/app/.next/server/chunks/1.js:2:2)\n at GET (/app/.next/server/chunks/route.js:9:9)';
|
|
await captureLoggedError('x failed', undefined, stack);
|
|
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
|
|
expect(event.exception.values[0].stacktrace.frames.map((f: { function: string }) => f.function)).toEqual(['GET']);
|
|
});
|
|
});
|