Files
webmail/lib/error-reporting/__tests__/desktop-reports.test.ts
T
Linus Rath 3d5b02b75f fix: stop a page reporting once consent goes, and let nothing on disk aim the desktop app's telemetry
A review found that an open page kept sending to a DSN after consent was
taken back, that a state file or config file could name where telemetry
goes, and that `BULWARK_TELEMETRY=off` did nothing on the desktop build.
A due heartbeat now waits a random 30 s to 10 min, a refusal from the
collector isn't retried, crash files go at sign-out and account switch,
and server builds parse stacks as they did.

Existing assertions changed in the desktop telemetry tests: the catch-up
wait is now a range, the endpoint comes from the shell, and the platform
is `desktop` against the collector's patched branch.
2026-11-03 17:38:04 +01:00

236 lines
9.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
captureException, configureErrorReporting, reportFromError, resetErrorReportingForTests,
} from '../client';
import { appVersion } from '../envelope';
import { scrubFramePath, scrubText } from '../scrub';
import { parseStack, sanitizeFrames } from '../stack';
// The desktop app's errors. Main's frames are the shell's own files as /desktop/...: they
// have to survive the check that drops reports with no frame of ours, and nothing about the
// user's disk may get through with them.
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
const SETTINGS = { dsn: DSN, sampleRate: 1, environment: 'production', collect: false };
const MAIN_STACK = [
"TypeError: Cannot read properties of undefined (reading 'x')",
' at sendPing (/desktop/main.mjs:120:7)',
' at async /desktop/main.mjs:90:3',
' at processTicksAndRejections (node:internal/process/task_queues:105:5)',
].join('\n');
const EXTENSION_STACK = 'TypeError: nope\n at x (chrome-extension://abc/content.js:1:2)\n at y (moz-extension://def/inject.js:3:4)';
function withStack(stack: string): Error {
const error = new TypeError('nope');
error.stack = stack;
return error;
}
function setBridge(bridge: unknown): void {
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge;
}
const forcedOff = { value: false, locked: true, source: 'machine' };
describe('main-process frames', () => {
beforeEach(() => {
// The rule is compiled into the desktop build only.
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('keeps a report whose frames are the shell\'s', () => {
const report = reportFromError(withStack(MAIN_STACK), 'desktop-main', false);
expect(report).not.toBeNull();
expect(report!.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, column: 7, fn: 'sendPing' });
expect(report!.frames[1].path).toBe('/desktop/main.mjs');
expect(report!.frames[2].path).toBe('node:internal/process/task_queues');
});
it('keeps one where every frame is the shell\'s', () => {
const only = 'TypeError: nope\n at a (/desktop/main.mjs:1:2)\n at b (/desktop/main.mjs:3:4)';
expect(reportFromError(withStack(only), 'desktop-main', false)?.frames).toHaveLength(2);
});
it('still drops a report from a browser extension', () => {
expect(reportFromError(withStack(EXTENSION_STACK), 'onerror', false)).toBeNull();
});
it('survives being posted and scrubbed again', () => {
const frames = parseStack(MAIN_STACK);
expect(sanitizeFrames(frames).map((frame) => frame.path)).toEqual(frames.map((frame) => frame.path));
});
it.each([
'C:\\Users\\linus\\AppData\\Local\\Programs\\Bulwark\\resources\\app.asar\\dist\\main.mjs',
'file:///C:/Users/linus/AppData/Local/Programs/Bulwark/resources/app.asar/dist/main.mjs',
'/home/linus/.local/bin/bulwark/resources/app.asar/dist/main.mjs',
'/desktop/../../Users/linus/secret.js',
'/desktop/C:\\Users\\linus\\x.js',
'/desktop/linus smith/x.js',
'https://evil.example/desktop/x.js',
])('gives nothing away for %s', (input) => {
expect(scrubFramePath(input)).toBe('[other]');
});
});
describe('a server install', () => {
it('sees no difference: a /desktop/ path is just another path', () => {
expect(scrubFramePath('/desktop/main.mjs')).toBe('[other]');
expect(parseStack('TypeError: x\n at async /desktop/main.mjs:90:3')[0].path).toBe('[other]');
expect(reportFromError(withStack('TypeError: x\n at a (/desktop/main.mjs:1:2)'), 'onerror', false)).toBeNull();
});
it('keeps reading an anonymous async frame as it did', () => {
// scrubFunctionName and the marker search see the same text as before this existed.
const [frame] = parseStack('TypeError: x\n at async https://mail.example.org/_next/static/chunks/1.js:2:30');
expect(frame.path).toBe('/_next/static/chunks/1.js');
expect(parseStack('TypeError: x\n at async node:internal/x:1:2')[0].path).toBe('[other]');
});
});
describe('what a report says', () => {
it.each([
["ENOENT: no such file or directory, open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\crash.dmp'", ['linus', 'AppData', 'crash']],
['EPERM: operation not permitted, mkdir C:\\Users\\linus\\mail', ['linus']],
['failed to read /home/linus/.config/Bulwark/state', ['linus', '.config']],
['Subject Merger plans for Acme failed from ceo.private@acme-corp.example', ['Merger', 'Acme', 'ceo.private', 'acme-corp']],
['request failed Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef sealed', ['eyJ', 'abcdef']],
['token 0123456789abcdef0123456789abcdef0123 refused', ['0123456789']],
])('%s', (input, gone) => {
const out = scrubText(input);
for (const word of gone) expect(out).not.toContain(word);
});
});
describe('tagged as the desktop app', () => {
let fetchSpy: ReturnType<typeof vi.fn>;
const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record<string, string> };
beforeEach(() => {
resetErrorReportingForTests();
sessionStorage.clear();
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
setBridge(undefined);
});
const bundleError = () => withStack('TypeError: nope\n at a (https://mail.example.org/_next/static/chunks/1.js:2:30)');
it('in the app, on the event that goes to a DSN', () => {
setBridge({ version: 1, platform: 'win32' });
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(eventOf(fetchSpy.mock.calls[0][1]).tags).toMatchObject({ runtime: 'browser', platform: 'desktop' });
});
it('not in a browser', () => {
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(eventOf(fetchSpy.mock.calls[0][1]).tags.platform).toBeUndefined();
});
it('a page in the app sends nothing with crash reports forced off, even to an instance that asks', () => {
setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: forcedOff }), onChange: () => () => {} } });
configureErrorReporting({ ...SETTINGS, collect: true });
captureException(bundleError());
expect(fetchSpy).not.toHaveBeenCalled();
});
it('a policy that only recommends leaves the instance\'s own settings in charge', () => {
setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: { value: false, locked: false, source: 'recommended' } }), onChange: () => () => {} } });
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
describe('consent taken back with a page open', () => {
function privacyBridge(on: boolean) {
let listener: ((state: unknown) => void) | undefined;
const say = (next: boolean, forcedOff = false) => listener?.({ telemetry: { on: false, forcedOff: false }, crashReports: { on: next, forcedOff, uploadUrl: '' } });
setBridge({
version: 1,
platform: 'win32',
mode: 'instance',
privacy: {
get: async () => ({ telemetry: { on: false, forcedOff: false }, crashReports: { on, forcedOff: false, uploadUrl: '' } }),
onChange: (next: (state: unknown) => void) => {
listener = next;
return () => {};
},
},
});
return say;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
it('stops an open page sending to a DSN', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
say(false);
captureException(withStack('TypeError: other\n at b (https://mail.example.org/_next/static/chunks/2.js:2:30)'));
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('and a config arriving afterwards doesn\'t bring it back', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
say(false);
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(fetchSpy).not.toHaveBeenCalled();
});
it('a change to something else, with crash reports never on, leaves an instance\'s own settings alone', async () => {
const say = privacyBridge(false);
configureErrorReporting(SETTINGS);
await settle();
say(false);
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('a policy forcing it off stops it, however it stood', async () => {
const say = privacyBridge(false);
configureErrorReporting(SETTINGS);
await settle();
say(false, true);
captureException(bundleError());
expect(fetchSpy).not.toHaveBeenCalled();
});
it('saying yes again lifts it', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
say(false);
say(true);
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
it('in the version, in the desktop build only: the collector has no room for a tag', () => {
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.20.0');
expect(appVersion()).toBe('1.20.0');
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect(appVersion()).toBe('1.20.0+desktop');
expect(appVersion()).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/);
});
});