Å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.
225 lines
7.1 KiB
TypeScript
225 lines
7.1 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import {
|
|
captureException,
|
|
captureMessage,
|
|
flushObservability,
|
|
getEnvironment,
|
|
getObservabilitySink,
|
|
getRelease,
|
|
isObservabilityConfigured,
|
|
noopSink,
|
|
registerObservabilitySink,
|
|
resetObservabilitySink,
|
|
type ObservabilityContext,
|
|
type ObservabilityLevel,
|
|
type ObservabilitySink,
|
|
} from '../sink'
|
|
|
|
interface Captured {
|
|
kind: 'exception' | 'message'
|
|
error?: unknown
|
|
message?: string
|
|
level?: ObservabilityLevel
|
|
context: ObservabilityContext
|
|
}
|
|
|
|
function makeFakeSink(): { sink: ObservabilitySink; events: Captured[]; flushes: number[] } {
|
|
const events: Captured[] = []
|
|
const flushes: number[] = []
|
|
const sink: ObservabilitySink = {
|
|
name: 'fake',
|
|
captureException(error, context) {
|
|
events.push({ kind: 'exception', error, context })
|
|
},
|
|
captureMessage(message, level, context) {
|
|
events.push({ kind: 'message', message, level, context })
|
|
},
|
|
async flush(timeoutMs) {
|
|
flushes.push(timeoutMs ?? -1)
|
|
return true
|
|
},
|
|
}
|
|
return { sink, events, flushes }
|
|
}
|
|
|
|
describe('observability sink', () => {
|
|
beforeEach(() => {
|
|
resetObservabilitySink()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
afterEach(() => {
|
|
resetObservabilitySink()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('defaults to the no-op sink and reports itself as unconfigured', () => {
|
|
expect(getObservabilitySink()).toBe(noopSink)
|
|
expect(isObservabilityConfigured()).toBe(false)
|
|
// Must not throw with no provider installed.
|
|
expect(() => captureException(new Error('boom'))).not.toThrow()
|
|
expect(() => captureMessage('hello', 'error')).not.toThrow()
|
|
})
|
|
|
|
it('registers an adapter and routes events to it', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
expect(isObservabilityConfigured()).toBe(true)
|
|
captureMessage('something happened', 'warning', { operation: 'test.op' })
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toMatchObject({
|
|
kind: 'message',
|
|
message: 'something happened',
|
|
level: 'warning',
|
|
})
|
|
expect(events[0].context.operation).toBe('test.op')
|
|
})
|
|
|
|
it('resetObservabilitySink restores the no-op default', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
resetObservabilitySink()
|
|
|
|
captureMessage('after reset', 'error')
|
|
expect(events).toHaveLength(0)
|
|
expect(isObservabilityConfigured()).toBe(false)
|
|
})
|
|
|
|
it('serializes Error instances before they reach the adapter', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
const err = new Error('database exploded')
|
|
;(err as Error & { code?: string }).code = '23505'
|
|
captureException(err, { operation: 'test.op' })
|
|
|
|
expect(events[0].error).not.toBeInstanceOf(Error)
|
|
expect(events[0].error).toMatchObject({
|
|
name: 'Error',
|
|
message: 'database exploded',
|
|
code: '23505',
|
|
})
|
|
})
|
|
|
|
it('redacts denylisted keys in context before the adapter sees them', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
captureMessage('login failed', 'error', {
|
|
user: 'alice',
|
|
personnummer: '800101-1234',
|
|
headers: { authorization: 'Bearer abc', cookie: 'sess=xyz' },
|
|
nested: { payload: { password: 'hunter2', iban: 'SE1234567890' } },
|
|
})
|
|
|
|
const ctx = events[0].context as {
|
|
user: string
|
|
personnummer: string
|
|
headers: { authorization: string; cookie: string }
|
|
nested: { payload: { password: string; iban: string } }
|
|
}
|
|
expect(ctx.personnummer).toBe('[REDACTED]')
|
|
expect(ctx.headers.authorization).toBe('[REDACTED]')
|
|
expect(ctx.headers.cookie).toBe('[REDACTED]')
|
|
expect(ctx.nested.payload.password).toBe('[REDACTED]')
|
|
expect(ctx.nested.payload.iban).toBe('[REDACTED]')
|
|
expect(ctx.user).toBe('alice')
|
|
})
|
|
|
|
it('redacts personnummer-shaped strings in messages, errors and context values', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
captureMessage('failed for 19800101-1234', 'error', { note: 'customer 800101-1234 blocked' })
|
|
captureException(new Error('no match for 800101-1234'), { note: 'ok' })
|
|
|
|
expect(events[0].message).toBe('[REDACTED]')
|
|
expect((events[0].context as { note: string }).note).toBe('[REDACTED]')
|
|
expect((events[1].error as { message: string }).message).toBe('[REDACTED]')
|
|
})
|
|
|
|
it('preserves UUIDs, which are not personnummer', () => {
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
captureMessage('ok', 'info', { companyId: '57484518-3409-4b29-9d23-5d22f08bda63' })
|
|
expect(events[0].context.companyId).toBe('57484518-3409-4b29-9d23-5d22f08bda63')
|
|
})
|
|
|
|
it('stamps release, environment and runtime, and callers cannot override them', () => {
|
|
vi.stubEnv('OBSERVABILITY_RELEASE', '')
|
|
vi.stubEnv('NEXT_PUBLIC_BUILD_ID', 'abc123sha')
|
|
vi.stubEnv('OBSERVABILITY_ENVIRONMENT', 'preview')
|
|
|
|
const { sink, events } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
|
|
captureMessage('tagged', 'error', { release: 'spoofed', environment: 'spoofed' })
|
|
|
|
expect(events[0].context.release).toBe('abc123sha')
|
|
expect(events[0].context.environment).toBe('preview')
|
|
expect(events[0].context.runtime).toBe('server')
|
|
})
|
|
|
|
it('reads the release from the same value next.config.ts inlines, with fallbacks', () => {
|
|
vi.stubEnv('OBSERVABILITY_RELEASE', '')
|
|
vi.stubEnv('NEXT_PUBLIC_BUILD_ID', '')
|
|
vi.stubEnv('VERCEL_GIT_COMMIT_SHA', '')
|
|
expect(getRelease()).toBeNull()
|
|
|
|
vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'runtime-sha')
|
|
expect(getRelease()).toBe('runtime-sha')
|
|
|
|
vi.stubEnv('NEXT_PUBLIC_BUILD_ID', 'build-sha')
|
|
expect(getRelease()).toBe('build-sha')
|
|
|
|
vi.stubEnv('OBSERVABILITY_RELEASE', 'explicit')
|
|
expect(getRelease()).toBe('explicit')
|
|
})
|
|
|
|
it('derives the environment from VERCEL_ENV when no override is set', () => {
|
|
vi.stubEnv('OBSERVABILITY_ENVIRONMENT', '')
|
|
vi.stubEnv('VERCEL_ENV', 'production')
|
|
expect(getEnvironment()).toBe('production')
|
|
})
|
|
|
|
it('never lets a throwing adapter escape into the caller', () => {
|
|
registerObservabilitySink({
|
|
name: 'broken',
|
|
captureException() {
|
|
throw new Error('provider down')
|
|
},
|
|
captureMessage() {
|
|
throw new Error('provider down')
|
|
},
|
|
async flush() {
|
|
throw new Error('provider down')
|
|
},
|
|
})
|
|
|
|
expect(() => captureException(new Error('app error'))).not.toThrow()
|
|
expect(() => captureMessage('app message', 'error')).not.toThrow()
|
|
})
|
|
|
|
it('flush returns true by default and false when the adapter rejects', async () => {
|
|
expect(await flushObservability()).toBe(true)
|
|
|
|
const { sink, flushes } = makeFakeSink()
|
|
registerObservabilitySink(sink)
|
|
expect(await flushObservability(500)).toBe(true)
|
|
expect(flushes).toEqual([500])
|
|
|
|
registerObservabilitySink({
|
|
name: 'broken',
|
|
captureException() {},
|
|
captureMessage() {},
|
|
async flush() {
|
|
throw new Error('provider down')
|
|
},
|
|
})
|
|
expect(await flushObservability()).toBe(false)
|
|
})
|
|
})
|