133 lines
4.8 KiB
JavaScript
133 lines
4.8 KiB
JavaScript
// 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);
|
|
});
|
|
});
|