import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as Sentry from '@sentry/browser' import { scrubEvent, pathnameOnly, buildSentryOptions, configureSentryReporting, reportError, reportUnexpectedApiFailure, resetSentryReportingForTests, IGNORED_ERROR_PATTERNS, NetworkFailureTracker, NETWORK_FAILURE_MIN_STREAK_MS, NETWORK_FAILURE_MAX_GAP_MS, NETWORK_FAILURE_MAX_GAP_HIDDEN_MS, NETWORK_FAILURE_MIN_FAILURES, } from '../src/utils/sentryReporting' import type { ErrorEvent as SentryEvent, EventHint } from '@sentry/browser' const hint = {} as EventHint describe('pathnameOnly', () => { it('strips the query string from an absolute URL', () => { expect(pathnameOnly('https://app.impri.dev/recover?project_id=proj_1&t=secretcode')).toBe('/recover') }) it('strips the query string from a relative path', () => { expect(pathnameOnly('/v1/actions?status=pending&q=refund')).toBe('/v1/actions') }) it('returns a bare relative path unchanged', () => { expect(pathnameOnly('/v1/billing')).toBe('/v1/billing') }) }) describe('scrubEvent', () => { it('reduces request.url to a pathname and drops query_string/data', () => { const event = { request: { url: 'https://app.impri.dev/recover?project_id=proj_1&t=secretcode', query_string: 'project_id=proj_1&t=secretcode', data: { recovery_code: 'secretcode' }, }, } as unknown as SentryEvent const scrubbed = scrubEvent(event, hint) expect(scrubbed.request?.url).toBe('/recover') expect(scrubbed.request?.query_string).toBeUndefined() expect((scrubbed.request as { data?: unknown })?.data).toBeUndefined() }) it('reduces the Referer header to a pathname', () => { const event = { request: { headers: { Referer: 'https://app.impri.dev/watchers?highlight=wat_1' } }, } as unknown as SentryEvent const scrubbed = scrubEvent(event, hint) expect(scrubbed.request?.headers?.['Referer']).toBe('/watchers') }) it('redacts email addresses in message and exception values', () => { const event = { message: 'Failed for someone@example.com', exception: { values: [{ value: 'contact ops@impri.dev for help' }] }, } as unknown as SentryEvent const scrubbed = scrubEvent(event, hint) expect(scrubbed.message).toBe('Failed for [email]') expect(scrubbed.exception?.values?.[0].value).toBe('contact [email] for help') }) it('redacts email addresses inside extra', () => { const event = { extra: { note: 'reported by a@b.com' } } as unknown as SentryEvent expect(scrubEvent(event, hint).extra).toEqual({ note: 'reported by [email]' }) }) it('redacts sensitive-looking keys in extra entirely, regardless of value type', () => { const event = { extra: { pathname: '/v1/watchers', api_key: 'im_supersecret', payload: { url: 'https://example.com', keywords: ['x'] }, authorization: 'Bearer im_supersecret', }, } as unknown as SentryEvent expect(scrubEvent(event, hint).extra).toEqual({ pathname: '/v1/watchers', api_key: '[REDACTED]', payload: '[REDACTED]', authorization: '[REDACTED]', }) }) it('always drops breadcrumbs', () => { const event = { breadcrumbs: [{ category: 'ui.click', message: 'clicked' }] } as unknown as SentryEvent expect(scrubEvent(event, hint).breadcrumbs).toBeUndefined() }) }) describe('IGNORED_ERROR_PATTERNS', () => { it('matches the Outlook SafeLinks noise message', () => { expect(IGNORED_ERROR_PATTERNS.some(p => p.test('Object Not Found Matching Id:4, MethodName:update, ParamCount:4'))).toBe(true) }) it('matches the generic cross-origin "Script error."', () => { expect(IGNORED_ERROR_PATTERNS.some(p => p.test('Script error.'))).toBe(true) }) it('does not match a real application error message', () => { expect(IGNORED_ERROR_PATTERNS.some(p => p.test('Cannot read properties of undefined'))).toBe(false) }) }) // The patterns above only matter if the SDK applies them, and it does that in // an integration this app has to opt into (defaultIntegrations is off). So // this boots the real SDK with the production options and looks at what // reaches the transport — in briefgate the same link-scanner error kept // arriving in Sentry while the pattern tests above were green. describe('Sentry booted with buildSentryOptions()', () => { const SCANNER_ERROR = 'Object Not Found Matching Id:3, MethodName:update, ParamCount:4' let sentBodies: string[] beforeEach(() => { sentBodies = [] Sentry.init({ ...buildSentryOptions(Sentry, { dsn: 'https://public@o0.ingest.sentry.io/0', environment: 'test' }), transport: transportOptions => Sentry.createTransport(transportOptions, async (request) => { sentBodies.push(String(request.body)) return { statusCode: 200 } }), }) }) afterEach(async () => { await Sentry.close() }) it('sends a real error, so an empty transport below means "filtered", not "broken"', async () => { Sentry.captureException(new Error('Cannot read properties of undefined')) await Sentry.flush() expect(sentBodies).toHaveLength(1) expect(sentBodies[0]).toContain('Cannot read properties of undefined') }) it('drops the link-scanner rejection, which arrives as a bare string reason', async () => { Sentry.captureException(SCANNER_ERROR) await Sentry.flush() expect(sentBodies).toHaveLength(0) }) it('drops the link-scanner error when it arrives as an Error object', async () => { Sentry.captureException(new Error(SCANNER_ERROR)) await Sentry.flush() expect(sentBodies).toHaveLength(0) }) it('drops the cross-origin "Script error." placeholder', async () => { Sentry.captureException('Script error.') await Sentry.flush() expect(sentBodies).toHaveLength(0) }) }) describe('reportError — no DSN configured = no init (structural no-op)', () => { beforeEach(() => { resetSentryReportingForTests() }) it('never imports @sentry/browser when configureSentryReporting was not called', async () => { // No configureSentryReporting() call at all in this test — config stays null. reportError(new Error('boom')) // Give any stray microtask a chance to run before asserting nothing happened. await Promise.resolve() // @sentry/browser is only reachable via dynamic import inside // ensureSentryInitialized(); if reportError() short-circuited (as it must // with no config), that import was never triggered. There's no client // object to assert on precisely because none was created — the absence // of a thrown error / hang here (no real network call attempted) is the // behavior under test. expect(true).toBe(true) }) it('stays a no-op after configureSentryReporting is called with an empty dsn', async () => { configureSentryReporting({ dsn: '', environment: 'production' }) reportError(new Error('boom')) await Promise.resolve() expect(true).toBe(true) }) }) describe('reportUnexpectedApiFailure', () => { it('does not call the report function for a 4xx status', () => { const report = vi.fn() reportUnexpectedApiFailure(new Error('bad request'), { status: 404, method: 'GET', path: '/v1/actions/x' }, report) expect(report).not.toHaveBeenCalled() }) it('reports with status/method/pathname (query string stripped) for a 5xx', () => { const report = vi.fn() const err = new Error('server blew up') reportUnexpectedApiFailure(err, { status: 500, method: 'POST', path: '/v1/actions?since=123' }, report) expect(report).toHaveBeenCalledWith(err, { status: 500, method: 'POST', pathname: '/v1/actions' }) }) it('defaults to the real reportError (a structural no-op with no DSN configured)', () => { resetSentryReportingForTests() expect(() => reportUnexpectedApiFailure(new Error('boom'), { status: 500, method: 'GET', path: '/v1/x' })).not.toThrow() }) }) describe('NetworkFailureTracker', () => { const info = { method: 'GET', path: '/v1/actions?status=pending' } const loadFailed = () => new TypeError('Load failed') const SEC = 1_000 /** A tracker on a fake clock: `fail(atMs)` records a failure at that time. * `online` / `hidden` are read on every failure, so a test can flip them. */ function setup() { const report = vi.fn() let clock = 0 const state = { online: true, hidden: false } const tracker = new NetworkFailureTracker({ report, isOnline: () => state.online, isHidden: () => state.hidden, now: () => clock, }) const fail = (atMs: number, err: unknown = loadFailed()) => { clock = atMs tracker.recordFailure(err, info) } return { report, tracker, fail, state } } it('pins the window the docs describe', () => { expect(NETWORK_FAILURE_MIN_STREAK_MS).toBe(60 * SEC) expect(NETWORK_FAILURE_MIN_FAILURES).toBe(3) expect(NETWORK_FAILURE_MAX_GAP_MS).toBe(30 * SEC) expect(NETWORK_FAILURE_MAX_GAP_HIDDEN_MS).toBe(90 * SEC) }) it('does not report a burst of failures right after waking up (IMPRI-WEB-3)', () => { const { report, tracker, fail } = setup() // First poll tick after wake fires two requests, the next one a third — // all before Wi-Fi is back — then the API answers. fail(0) fail(0) fail(5 * SEC) fail(10 * SEC) tracker.recordResponse() expect(report).not.toHaveBeenCalled() }) it('does not report however many failures arrive within the first minute', () => { const { report, fail } = setup() for (let t = 0; t < 60 * SEC; t += 2 * SEC) fail(t) expect(report).not.toHaveBeenCalled() }) it('reports once when polls keep failing for a minute, not on every later poll', () => { const { report, fail } = setup() const err = loadFailed() for (let t = 0; t < 60 * SEC; t += 5 * SEC) fail(t) expect(report).not.toHaveBeenCalled() fail(60 * SEC, err) for (let t = 65 * SEC; t <= 90 * SEC; t += 5 * SEC) fail(t) expect(report).toHaveBeenCalledTimes(1) expect(report).toHaveBeenCalledWith(err, { status: 'network_error', method: 'GET', pathname: '/v1/actions', consecutive_failures: 13, unanswered_for_s: 60, }) }) it('does not count time the page was not running (laptop asleep) as an outage', () => { const { report, fail } = setup() fail(0) // cut off as the lid closed const wake = 8 * 3600 * SEC fail(wake) fail(wake + 5 * SEC) expect(report).not.toHaveBeenCalled() // Still failing a full minute after waking up is an outage again. for (let t = wake + 10 * SEC; t <= wake + 60 * SEC; t += 5 * SEC) fail(t) expect(report).toHaveBeenCalledTimes(1) expect(report.mock.calls[0]?.[1]).toMatchObject({ unanswered_for_s: 60 }) }) it('treats failures exactly MAX_GAP apart as one streak, and further apart as two', () => { const joined = setup() joined.fail(0) joined.fail(30 * SEC) joined.fail(60 * SEC) expect(joined.report).toHaveBeenCalledTimes(1) expect(joined.report.mock.calls[0]?.[1]).toMatchObject({ consecutive_failures: 3 }) const split = setup() split.fail(0) split.fail(30 * SEC + 1) split.fail(60 * SEC + 2) expect(split.report).not.toHaveBeenCalled() }) it('reports an outage seen only by a hidden tab whose polls are throttled to once a minute', () => { const { report, fail, state } = setup() state.hidden = true fail(0) fail(60 * SEC) // Two failures already span the window, but one each side of a short // laptop sleep looks exactly like this — the third one decides. expect(report).not.toHaveBeenCalled() fail(120 * SEC) expect(report).toHaveBeenCalledTimes(1) expect(report.mock.calls[0]?.[1]).toMatchObject({ consecutive_failures: 3, unanswered_for_s: 120 }) }) it('starts a new streak in a hidden tab when failures are more than MAX_GAP_HIDDEN apart', () => { const { report, fail, state } = setup() state.hidden = true fail(0) fail(90 * SEC + 1) fail(180 * SEC + 2) expect(report).not.toHaveBeenCalled() }) it('starts the streak over when the browser goes offline in the middle of it', () => { const { report, fail, state } = setup() for (let t = 0; t <= 50 * SEC; t += 5 * SEC) fail(t) state.online = false fail(55 * SEC) state.online = true for (let t = 60 * SEC; t <= 115 * SEC; t += 5 * SEC) fail(t) expect(report).not.toHaveBeenCalled() fail(120 * SEC) expect(report).toHaveBeenCalledTimes(1) expect(report.mock.calls[0]?.[1]).toMatchObject({ unanswered_for_s: 60 }) }) it('starts a new streak after any response, so a later outage is reported again', () => { const { report, tracker, fail } = setup() for (let t = 0; t <= 55 * SEC; t += 5 * SEC) fail(t) tracker.recordResponse() for (let t = 60 * SEC; t <= 115 * SEC; t += 5 * SEC) fail(t) expect(report).not.toHaveBeenCalled() fail(120 * SEC) tracker.recordResponse() for (let t = 200 * SEC; t <= 260 * SEC; t += 5 * SEC) fail(t) expect(report).toHaveBeenCalledTimes(2) }) it('ignores failures while the browser reports being offline', () => { const { report, fail, state } = setup() state.online = false for (let t = 0; t <= 120 * SEC; t += 5 * SEC) fail(t) expect(report).not.toHaveBeenCalled() }) it('ignores requests the page aborted itself', () => { const { report, fail } = setup() const abort = () => { const err = new Error('The user aborted a request.') err.name = 'AbortError' return err } for (let t = 0; t <= 120 * SEC; t += 5 * SEC) fail(t, abort()) expect(report).not.toHaveBeenCalled() }) }) afterEach(() => { resetSentryReportingForTests() vi.restoreAllMocks() })