Å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.
272 lines
8.7 KiB
TypeScript
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)
|
|
})
|
|
})
|