Ctrl K
ComunidadGuías…

ui/tests/sentryReporting.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()
})