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
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { isConfigData } from '@/lib/config-validation';
|
|
import { createConfig } from './fixtures/config';
|
|
|
|
const server = {
|
|
id: 'primary',
|
|
label: 'Primary mail server',
|
|
url: 'https://mail.example.com',
|
|
domains: ['example.com'],
|
|
};
|
|
|
|
describe('server configuration validation', () => {
|
|
it('accepts a complete response without changing its values', () => {
|
|
const config = createConfig();
|
|
const before = structuredClone(config);
|
|
|
|
expect(isConfigData(config)).toBe(true);
|
|
expect(config).toEqual(before);
|
|
});
|
|
|
|
it('preserves empty strings, disabled flags and an empty server list', () => {
|
|
const config = Object.fromEntries(Object.entries(createConfig()).map(([key, value]) => [
|
|
key,
|
|
typeof value === 'string' ? '' : typeof value === 'boolean' ? false : [],
|
|
]));
|
|
|
|
expect(isConfigData(config)).toBe(true);
|
|
});
|
|
|
|
it('accepts a server list without a default URL and preserves OAuth and unknown fields', () => {
|
|
const config = {
|
|
...createConfig({ jmapServerUrl: '', jmapServerAutoPickByDomain: true, oauthEnabled: true, oauthOnly: true }),
|
|
jmapServers: [
|
|
server,
|
|
{ ...server, id: 'secondary', domains: [], oauth: { clientId: 'webmail', issuerUrl: 'https://sso.example.com' }, futureServerOption: true },
|
|
],
|
|
futureConfigOption: { enabled: true },
|
|
};
|
|
const before = structuredClone(config);
|
|
|
|
expect(isConfigData(config)).toBe(true);
|
|
expect(config).toEqual(before);
|
|
});
|
|
|
|
it.each([
|
|
{},
|
|
{ clientId: 'webmail' },
|
|
{ issuerUrl: 'https://sso.example.com' },
|
|
{ clientId: '', issuerUrl: '' },
|
|
])('accepts optional public OAuth fields: %j', (oauth) => {
|
|
expect(isConfigData(createConfig({ jmapServers: [{ ...server, oauth }] }))).toBe(true);
|
|
});
|
|
|
|
it.each([null, undefined, false, 1, 'config', [], {}].map((value) => ({ value })))('rejects a non-configuration response: $value', ({ value }) => {
|
|
expect(isConfigData(value)).toBe(false);
|
|
});
|
|
|
|
it('rejects a partial response containing only the app name and server URL', () => {
|
|
expect(isConfigData({ appName: 'Example Mail', jmapServerUrl: 'https://mail.example.com' })).toBe(false);
|
|
});
|
|
|
|
it.each(Object.keys(createConfig()))('rejects a response missing required field %s', (key) => {
|
|
const config: Record<string, unknown> = { ...createConfig() };
|
|
delete config[key];
|
|
|
|
expect(isConfigData(config)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['appName', 42],
|
|
['jmapServerUrl', null],
|
|
['oauthEnabled', 'false'],
|
|
['oauthOnly', 0],
|
|
['oauthClientId', false],
|
|
['loginLogoLightUrl', {}],
|
|
['loginShowHeading', null],
|
|
['allowCustomJmapEndpoint', 'true'],
|
|
['parentOrigin', ['https://parent.example.com']],
|
|
])('rejects an incorrectly typed %s', (key, value) => {
|
|
expect(isConfigData({ ...createConfig(), [key as string]: value })).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
{},
|
|
'servers',
|
|
[null],
|
|
['primary'],
|
|
[{ ...server, id: 1 }],
|
|
[{ ...server, label: false }],
|
|
[{ ...server, url: {} }],
|
|
[{ ...server, domains: 'example.com' }],
|
|
[{ ...server, domains: [1] }],
|
|
[{ ...server, oauth: null }],
|
|
[{ ...server, oauth: [] }],
|
|
[{ ...server, oauth: 'oauth' }],
|
|
[{ ...server, oauth: { clientId: false } }],
|
|
[{ ...server, oauth: { issuerUrl: 42 } }],
|
|
].map((jmapServers) => ({ jmapServers })))('rejects malformed server entries: $jmapServers', ({ jmapServers }) => {
|
|
expect(isConfigData({ ...createConfig(), jmapServers })).toBe(false);
|
|
});
|
|
|
|
it.each(['id', 'label', 'url', 'domains'])('rejects a server entry missing %s', (key) => {
|
|
const entry: Record<string, unknown> = { ...server };
|
|
delete entry[key];
|
|
|
|
expect(isConfigData({ ...createConfig(), jmapServers: [entry] })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('error reporting settings in the server configuration', () => {
|
|
const settings = { dsn: '', sampleRate: 1, environment: 'production', collect: false };
|
|
|
|
it('are optional, for Lite and servers before they existed', () => {
|
|
expect(isConfigData({ ...createConfig(), errorReporting: settings })).toBe(true);
|
|
expect(isConfigData(createConfig())).toBe(true);
|
|
});
|
|
|
|
it('are refused when malformed', () => {
|
|
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, sampleRate: '1' } })).toBe(false);
|
|
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, collect: 'yes' } })).toBe(false);
|
|
expect(isConfigData({ ...createConfig(), errorReporting: 'https://key@x.example/1' })).toBe(false);
|
|
});
|
|
});
|