// Sentry init wrapper — guarantees graceful no-op when SENTRY_DSN is // absent, scrubs PII on send when configured, and exposes a usable // surface to the rest of the codebase regardless of init state. // Mock @sentry/node BEFORE requiring our wrapper so we control what // init() and setupExpressErrorHandler do. const mockSentryInit = jest.fn(); const mockSentryCapture = jest.fn(); const mockSentryHandler = jest.fn(); jest.mock('@sentry/node', () => ({ init: (...a) => mockSentryInit(...a), captureException: (...a) => mockSentryCapture(...a), captureMessage: jest.fn(), setupExpressErrorHandler: (...a) => mockSentryHandler(...a), addBreadcrumb: jest.fn(), setUser: jest.fn(), setTag: jest.fn(), setContext: jest.fn(), })); const sentryWrapper = require('../../src/utils/sentry'); beforeEach(() => { mockSentryInit.mockReset(); mockSentryCapture.mockReset(); mockSentryHandler.mockReset(); sentryWrapper.__internals.__resetForTests(); delete process.env.SENTRY_DSN; }); describe('initSentry', () => { test('no-op when SENTRY_DSN is unset (sentry.init NOT called)', () => { sentryWrapper.initSentry(); expect(mockSentryInit).not.toHaveBeenCalled(); expect(sentryWrapper.isInitialized()).toBe(false); }); test('initializes when SENTRY_DSN is set', () => { process.env.SENTRY_DSN = 'https://abc@sentry.io/123'; sentryWrapper.initSentry(); expect(mockSentryInit).toHaveBeenCalledTimes(1); expect(sentryWrapper.isInitialized()).toBe(true); const cfg = mockSentryInit.mock.calls[0][0]; expect(cfg.dsn).toBe('https://abc@sentry.io/123'); expect(cfg.tracesSampleRate).toBe(0.1); expect(cfg.sendDefaultPii).toBe(false); expect(typeof cfg.beforeSend).toBe('function'); }); test('explicit dsn arg overrides env', () => { process.env.SENTRY_DSN = 'env-dsn'; sentryWrapper.initSentry({ dsn: 'arg-dsn' }); expect(mockSentryInit.mock.calls[0][0].dsn).toBe('arg-dsn'); }); test('idempotent — second call is a no-op', () => { process.env.SENTRY_DSN = 'https://abc@sentry.io/123'; sentryWrapper.initSentry(); sentryWrapper.initSentry(); expect(mockSentryInit).toHaveBeenCalledTimes(1); }); }); describe('beforeSend PII scrubbing', () => { beforeEach(() => { process.env.SENTRY_DSN = 'https://abc@sentry.io/123'; sentryWrapper.initSentry(); }); test('strips user.ip_address and user.email', () => { const cfg = mockSentryInit.mock.calls[0][0]; const event = { user: { id: 'u1', ip_address: '1.2.3.4', email: 'a@b.com' } }; const scrubbed = cfg.beforeSend(event); expect(scrubbed.user.id).toBe('u1'); expect(scrubbed.user.ip_address).toBeUndefined(); expect(scrubbed.user.email).toBeUndefined(); }); test('strips cookies + authorization + internal-key headers from request', () => { const cfg = mockSentryInit.mock.calls[0][0]; const event = { request: { cookies: { session: 'abc' }, headers: { authorization: 'Bearer secret', cookie: 'session=abc', 'x-internal-key': 'leak', 'x-vyndr-internal-key': 'leak', 'content-type': 'application/json', }, }, }; const scrubbed = cfg.beforeSend(event); expect(scrubbed.request.cookies).toBeUndefined(); expect(scrubbed.request.headers.authorization).toBeUndefined(); expect(scrubbed.request.headers.cookie).toBeUndefined(); expect(scrubbed.request.headers['x-internal-key']).toBeUndefined(); expect(scrubbed.request.headers['x-vyndr-internal-key']).toBeUndefined(); // Non-sensitive headers are kept. expect(scrubbed.request.headers['content-type']).toBe('application/json'); }); }); describe('noop client surface', () => { test('captureException is callable without DSN (no throw)', () => { sentryWrapper.initSentry(); expect(() => sentryWrapper.Sentry.captureException(new Error('test'))).not.toThrow(); expect(mockSentryCapture).not.toHaveBeenCalled(); }); test('setupExpressErrorHandler is callable without DSN (no throw)', () => { sentryWrapper.initSentry(); expect(() => sentryWrapper.Sentry.setupExpressErrorHandler({})).not.toThrow(); expect(mockSentryHandler).not.toHaveBeenCalled(); }); test('after init, captureException routes to real Sentry', () => { process.env.SENTRY_DSN = 'https://abc@sentry.io/123'; sentryWrapper.initSentry(); const err = new Error('boom'); sentryWrapper.Sentry.captureException(err); expect(mockSentryCapture).toHaveBeenCalledWith(err); }); test('after init, setupExpressErrorHandler delegates to real Sentry', () => { process.env.SENTRY_DSN = 'https://abc@sentry.io/123'; sentryWrapper.initSentry(); const app = {}; sentryWrapper.Sentry.setupExpressErrorHandler(app); expect(mockSentryHandler).toHaveBeenCalledWith(app); }); });