Files
accounted/lib/__tests__/cron-reporting.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

272 lines
8.7 KiB
TypeScript

/**
* Cron failure reporting (lib/auth/cron.ts).
*
* Lives here rather than next to lib/auth/__tests__/cron.test.ts, which covers
* the constant-time secret comparison and is left untouched.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
CRON_REPORT_THROTTLE_MS,
reportCronFailure,
reportCronSuccess,
resetCronReportingState,
shouldReportCronFailure,
verifyCronSecret,
} from '../auth/cron'
import {
registerObservabilitySink,
resetObservabilitySink,
type ObservabilityContext,
type ObservabilityLevel,
} from '../observability/sink'
interface Captured {
kind: 'exception' | 'message'
error?: unknown
message?: string
level?: ObservabilityLevel
context: ObservabilityContext
}
let events: Captured[] = []
beforeEach(() => {
events = []
resetCronReportingState()
vi.unstubAllEnvs()
registerObservabilitySink({
name: 'test',
captureException(error, context) {
events.push({ kind: 'exception', error, context })
},
captureMessage(message, level, context) {
events.push({ kind: 'message', message, level, context })
},
async flush() {
return true
},
})
})
afterEach(() => {
resetObservabilitySink()
resetCronReportingState()
vi.unstubAllEnvs()
})
describe('shouldReportCronFailure', () => {
it('reports the first failure by default', () => {
expect(shouldReportCronFailure({ consecutiveFailures: 1, lastReportAt: null })).toBe(true)
})
it('waits for the threshold when one is given', () => {
expect(
shouldReportCronFailure({ consecutiveFailures: 2, lastReportAt: null, threshold: 3 }),
).toBe(false)
expect(
shouldReportCronFailure({ consecutiveFailures: 3, lastReportAt: null, threshold: 3 }),
).toBe(true)
})
it('throttles repeats inside the window and allows them after it', () => {
const now = 1_000_000_000
expect(
shouldReportCronFailure({ consecutiveFailures: 5, lastReportAt: now - 1000, now }),
).toBe(false)
expect(
shouldReportCronFailure({
consecutiveFailures: 5,
lastReportAt: now - CRON_REPORT_THROTTLE_MS - 1,
now,
}),
).toBe(true)
})
})
describe('reportCronFailure', () => {
it('sends a message event with cron context when there is no error object', () => {
reportCronFailure({ operation: 'cron.invoice-reminders', status: 500, reason: 'timeout' })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('message')
expect(events[0].level).toBe('error')
expect(events[0].context).toMatchObject({
cron: true,
alert: true,
operation: 'cron.invoice-reminders',
cronKind: 'run',
status: 500,
reason: 'timeout',
consecutiveFailures: 1,
})
})
it('sends an exception event when an error is supplied', () => {
reportCronFailure({
operation: 'cron.documents-verify',
error: new Error('storage unreachable'),
itemsTotal: 10,
itemsFailed: 4,
})
expect(events[0].kind).toBe('exception')
expect(events[0].error).toMatchObject({ name: 'Error', message: 'storage unreachable' })
expect(events[0].context).toMatchObject({ itemsTotal: 10, itemsFailed: 4 })
})
it('throttles a storm of failures from one operation into a single event', () => {
for (let i = 0; i < 500; i++) {
reportCronFailure({ operation: 'cron.accruals', kind: 'item', reason: 'boom' })
}
expect(events).toHaveLength(1)
})
it('keeps different operations and kinds on separate throttles', () => {
reportCronFailure({ operation: 'cron.a', kind: 'run' })
reportCronFailure({ operation: 'cron.a', kind: 'auth' })
reportCronFailure({ operation: 'cron.b', kind: 'run' })
expect(events).toHaveLength(3)
})
it('reports again once the throttle window has passed, with a growing streak', () => {
const t0 = 1_000_000_000
reportCronFailure({ operation: 'cron.a', now: t0 })
reportCronFailure({ operation: 'cron.a', now: t0 + 1000 })
reportCronFailure({ operation: 'cron.a', now: t0 + CRON_REPORT_THROTTLE_MS + 1 })
expect(events).toHaveLength(2)
expect(events[0].context.consecutiveFailures).toBe(1)
expect(events[1].context.consecutiveFailures).toBe(3)
})
it('reportCronSuccess clears the streak so the next outage reports fresh', () => {
const t0 = 1_000_000_000
reportCronFailure({ operation: 'cron.a', now: t0 })
reportCronSuccess('cron.a')
reportCronFailure({ operation: 'cron.a', now: t0 + 1000 })
expect(events).toHaveLength(2)
expect(events[1].context.consecutiveFailures).toBe(1)
})
it('evicts only the oldest tracked key at capacity, keeping live throttles intact', () => {
const t0 = 1_000_000_000
// Fill the map to MAX_TRACKED_CRON_KEYS (200 in lib/auth/cron.ts).
for (let i = 0; i < 200; i++) {
reportCronFailure({ operation: `cron.op-${i}`, now: t0 })
}
events.length = 0
// A new key at capacity used to clear() the whole map; now it evicts the
// single oldest entry (cron.op-0).
reportCronFailure({ operation: 'cron.new', now: t0 + 1 })
expect(events).toHaveLength(1)
// cron.op-199's throttle survived: an in-progress storm stays damped
// instead of re-reporting immediately after the map was cleared.
reportCronFailure({ operation: 'cron.op-199', now: t0 + 2 })
expect(events).toHaveLength(1)
// cron.op-0 was the one evicted: its next failure reports from a fresh
// streak rather than inheriting the old one.
reportCronFailure({ operation: 'cron.op-0', now: t0 + 3 })
expect(events).toHaveLength(2)
expect(events[1].context).toMatchObject({
operation: 'cron.op-0',
consecutiveFailures: 1,
})
})
it('redacts PII in the supplied context before it reaches the sink', () => {
reportCronFailure({
operation: 'cron.salary',
context: { note: 'employee 800101-1234 skipped', token: 'abc123' },
})
const ctx = events[0].context as { note: string; token: string }
expect(ctx.note).toBe('[REDACTED]')
expect(ctx.token).toBe('[REDACTED]')
})
it('never throws, even when the sink is broken', () => {
registerObservabilitySink({
name: 'broken',
captureException() {
throw new Error('provider down')
},
captureMessage() {
throw new Error('provider down')
},
async flush() {
return false
},
})
expect(() => reportCronFailure({ operation: 'cron.a' })).not.toThrow()
})
})
describe('verifyCronSecret reporting', () => {
function cronRequest(auth?: string): Request {
return new Request('http://localhost/api/invoices/recurring/cron', {
headers: auth ? { authorization: auth } : {},
})
}
it('reports a missing CRON_SECRET, the config error that silently kills every job', () => {
vi.stubEnv('CRON_SECRET', '')
const result = verifyCronSecret(cronRequest('Bearer whatever'))
expect(result?.status).toBe(401)
expect(events).toHaveLength(1)
expect(events[0].context).toMatchObject({
cron: true,
alert: true,
cronKind: 'auth',
status: 401,
reason: 'secret_not_configured',
operation: '/api/invoices/recurring/cron',
})
})
it('reports a token mismatch, i.e. a rotated secret the scheduler still has', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
const result = verifyCronSecret(cronRequest('Bearer stale-secret'))
expect(result?.status).toBe(401)
expect(events[0].context.reason).toBe('token_mismatch')
})
it('reports a missing authorization header', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest())
expect(events[0].context.reason).toBe('missing_authorization')
})
it('uses an explicit operation name when one is given', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer nope'), { operation: 'cron.invoice-reminders' })
expect(events[0].context.operation).toBe('cron.invoice-reminders')
})
it('reports nothing on a successful auth, and clears the streak', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer wrong'))
expect(events).toHaveLength(1)
expect(verifyCronSecret(cronRequest('Bearer correct-secret'))).toBeNull()
expect(events).toHaveLength(1)
// Streak was cleared, so the next failure reports immediately rather than
// being swallowed by the previous report's throttle window.
verifyCronSecret(cronRequest('Bearer wrong'))
expect(events).toHaveLength(2)
expect(events[1].context.consecutiveFailures).toBe(1)
})
it('can be told not to report', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer wrong'), { report: false })
expect(events).toHaveLength(0)
})
})