Files
accounted/lib/__tests__/logger-sink.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

255 lines
9.1 KiB
TypeScript

/**
* The logger -> observability sink bridge.
*
* The redaction assertions here are the load-bearing ones: these log records
* carry personnummer and financial data, and the sink ships them to a third
* party. If any of these fail, redaction is being bypassed on the way out.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createLogger } from '../logger'
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 = []
// The logger still writes to the console; keep the suite quiet.
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
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()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('logger -> observability sink', () => {
it('forwards every error record with its bound context', () => {
const log = createLogger('test/module', { requestId: 'req_1' }).child({ companyId: 'co_1' })
const err = new Error('insert failed')
;(err as Error & { code?: string }).code = '23505'
log.error('could not save entry', err, { operation: 'journal.commit' })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('exception')
expect(events[0].error).toMatchObject({ name: 'Error', message: 'insert failed', code: '23505' })
expect(events[0].context).toMatchObject({
module: 'test/module',
logLevel: 'error',
logMessage: 'could not save entry',
requestId: 'req_1',
companyId: 'co_1',
operation: 'journal.commit',
})
})
it('forwards an error record with no Error object as a message', () => {
const log = createLogger('m')
log.error('something went wrong', { status: 500 })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('message')
expect(events[0].message).toBe('something went wrong')
expect(events[0].level).toBe('error')
expect(events[0].context.status).toBe(500)
})
it('forwards alert-flagged info/warn at their own severity, not as error', () => {
const log = createLogger('m')
log.warn('tax table stale', { alert: true })
log.info('backup finished late', { alert: true })
expect(events).toHaveLength(2)
expect(events[0]).toMatchObject({ kind: 'message', level: 'warning' })
expect(events[1]).toMatchObject({ kind: 'message', level: 'info' })
})
it('does not forward plain info/warn records', () => {
const log = createLogger('m')
log.info('routine')
log.warn('mildly odd')
expect(events).toHaveLength(0)
})
it('forwards info/warn records flagged alert: true', () => {
const log = createLogger('m')
log.warn('year-end omföring skipped', { alert: true, companyId: 'co_1' })
expect(events).toHaveLength(1)
expect(events[0].context).toMatchObject({ alert: true, logLevel: 'warn', companyId: 'co_1' })
})
it('honours alert: true bound on a child logger', () => {
const log = createLogger('m').child({ alert: true })
log.info('this must page someone')
expect(events).toHaveLength(1)
expect(events[0].context.alert).toBe(true)
})
// --- redaction: the sink must never receive PII ---------------------------
it('strips a personnummer from the message before the sink sees it', () => {
const log = createLogger('m')
log.error('could not match payment for 800101-1234')
expect(events).toHaveLength(1)
expect(events[0].message).toBe('[REDACTED]')
expect(events[0].context.logMessage).toBe('[REDACTED]')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
it('strips a personnummer from context values and from the error message', () => {
const log = createLogger('m')
log.error('rot/rut lookup failed', new Error('no customer with 19800101-1234'), {
note: 'belongs to 800101-1234',
companyId: '57484518-3409-4b29-9d23-5d22f08bda63',
})
expect(events[0].context.note).toBe('[REDACTED]')
expect((events[0].error as { message: string }).message).toBe('[REDACTED]')
// UUIDs survive: they are identifiers, not personnummer.
expect(events[0].context.companyId).toBe('57484518-3409-4b29-9d23-5d22f08bda63')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
it('strips denylisted keys, however deeply nested, before the sink sees them', () => {
const log = createLogger('m')
log.error('provider call failed', {
user: 'alice',
personnummer: '800101-1234',
iban: 'SE4550000000058398257466',
headers: { authorization: 'Bearer super-secret', cookie: 'sess=abc' },
body: { credentials: { password: 'hunter2', api_key: 'gnubok_sk_live_xyz' } },
})
const ctx = events[0].context as {
user: string
personnummer: string
iban: string
headers: { authorization: string; cookie: string }
body: { credentials: unknown }
}
expect(ctx.personnummer).toBe('[REDACTED]')
expect(ctx.iban).toBe('[REDACTED]')
expect(ctx.headers.authorization).toBe('[REDACTED]')
expect(ctx.headers.cookie).toBe('[REDACTED]')
expect(ctx.body.credentials).toBe('[REDACTED]')
expect(ctx.user).toBe('alice')
const serialized = JSON.stringify(events[0])
expect(serialized).not.toContain('super-secret')
expect(serialized).not.toContain('hunter2')
expect(serialized).not.toContain('gnubok_sk_live_xyz')
expect(serialized).not.toContain('SE4550000000058398257466')
})
it('redacts PII carried in trailing details args', () => {
const log = createLogger('m')
log.error('legacy call', 'customer 800101-1234 not found', 42)
expect(events[0].context.details).toEqual(['[REDACTED]', 42])
})
it('scrubs API keys, emails and IBANs from free-text messages, not just denylisted keys', () => {
const log = createLogger('m')
log.error('key gnubok_sk_live_secret1 for anna@example.com failed on SE4550000000058398257466')
expect(events[0].message).toBe(
'key [REDACTED_API_KEY] for [REDACTED_EMAIL] failed on [REDACTED_IBAN]',
)
const serialized = JSON.stringify(events[0])
expect(serialized).not.toContain('gnubok_sk_live_secret1')
expect(serialized).not.toContain('anna@example.com')
expect(serialized).not.toContain('SE4550000000058398257466')
})
// --- stacks: the sink keeps them, production stdout drops them -------------
it('in production the sink receives a stack while the stdout JSON line drops it', () => {
vi.stubEnv('NODE_ENV', 'production')
const log = createLogger('m')
log.error('insert failed', new Error('clean failure'))
// Sink path: the error tracker only runs in production, so this is the
// one environment where a missing stack would make every event ungroupable.
const sinkErr = events[0].error as { message: string; stack?: string }
expect(typeof sinkErr.stack).toBe('string')
expect(sinkErr.stack).toContain('clean failure')
// Stdout path: unchanged contract, production log lines stay stackless.
const line = (console.error as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string
const parsed = JSON.parse(line) as { err: { message: string; stack?: unknown } }
expect(parsed.err.message).toBe('clean failure')
expect(parsed.err.stack).toBeUndefined()
})
it('the stack reaching the sink in production is redacted', () => {
vi.stubEnv('NODE_ENV', 'production')
const log = createLogger('m')
log.error('lookup failed', new Error('no customer with 800101-1234'))
const sinkErr = events[0].error as { message: string; stack?: string }
// The first stack line carries the message, so the whole-string
// personnummer rule nukes the stack rather than leaking it.
expect(sinkErr.message).toBe('[REDACTED]')
expect(sinkErr.stack).toBe('[REDACTED]')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
// --- resilience ----------------------------------------------------------
it('keeps logging when the sink throws', () => {
registerObservabilitySink({
name: 'broken',
captureException() {
throw new Error('provider down')
},
captureMessage() {
throw new Error('provider down')
},
async flush() {
return false
},
})
const log = createLogger('m')
expect(() => log.error('still works', new Error('boom'))).not.toThrow()
expect(console.error).toHaveBeenCalled()
})
it('is inert when no provider is registered', () => {
resetObservabilitySink()
const log = createLogger('m')
expect(() => log.error('no sink installed', new Error('boom'))).not.toThrow()
expect(events).toHaveLength(0)
})
})