Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions kun/src/contracts/fault-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export const FAULT_INJECTION_KINDS = [
'disk-full',
'permission-denied',
'child-process-crash',
'runtime-sigterm',
'renderer-crash',
'sse-disconnect',
'out-of-order-event',
'http-timeout',
'http-429',
'invalid-json',
'sqlite-busy',
'credential-store-unavailable'
] as const
export type FaultInjectionKind = typeof FAULT_INJECTION_KINDS[number]

export type FaultInjectionSpec = {
kind: FaultInjectionKind
enabled: boolean
once: boolean
delayMs: number
}

export type FaultInjectionValidationError =
| 'not-an-object'
| 'unknown-field'
| 'invalid-kind'
| 'invalid-enabled'
| 'invalid-once'
| 'invalid-delay'

export type FaultInjectionValidation =
| { ok: true; value: FaultInjectionSpec }
| { ok: false; error: FaultInjectionValidationError }

const MAX_DELAY_MS = 60_000

export function normalizeFaultInjectionSpec(input: unknown): FaultInjectionValidation {
if (!isRecord(input)) return { ok: false, error: 'not-an-object' }
if (!hasOnlyKeys(input, ['kind', 'enabled', 'once', 'delayMs'])) {
return { ok: false, error: 'unknown-field' }
}
if (!FAULT_INJECTION_KINDS.includes(input.kind as FaultInjectionKind)) {
return { ok: false, error: 'invalid-kind' }
}
if (typeof input.enabled !== 'boolean') return { ok: false, error: 'invalid-enabled' }
const once = input.once === undefined ? true : input.once
if (typeof once !== 'boolean') return { ok: false, error: 'invalid-once' }
const delayMs = input.delayMs === undefined ? 0 : input.delayMs
if (typeof delayMs !== 'number' || !Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > MAX_DELAY_MS) {
return { ok: false, error: 'invalid-delay' }
}
return { ok: true, value: { kind: input.kind as FaultInjectionKind, enabled: input.enabled, once, delayMs } }
}

export function shouldInjectFault(spec: unknown, activationCount: number): boolean {
const normalized = normalizeFaultInjectionSpec(spec)
if (!normalized.ok || !normalized.value.enabled ||
!Number.isSafeInteger(activationCount) || activationCount < 0) return false
return !normalized.value.once || activationCount === 0
}

function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === 'object' && input !== null && !Array.isArray(input)
}

function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
const allowed = new Set(keys)
return Object.keys(value).every((key) => allowed.has(key))
}
55 changes: 55 additions & 0 deletions kun/tests/fault-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { normalizeFaultInjectionSpec, shouldInjectFault } from '../src/contracts/fault-injection.js'

describe('fault injection contract', () => {
it('defaults to disabled, one activation, and no delay', () => {
expect(normalizeFaultInjectionSpec({ kind: 'http-timeout', enabled: false })).toEqual({
ok: true,
value: { kind: 'http-timeout', enabled: false, once: true, delayMs: 0 }
})
})

it('allows a bounded delayed repeated fault for test harnesses', () => {
const result = normalizeFaultInjectionSpec({
kind: 'sse-disconnect',
enabled: true,
once: false,
delayMs: 1_000
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(shouldInjectFault(result.value, 0)).toBe(true)
expect(shouldInjectFault(result.value, 100)).toBe(true)
}
})

it('only allows the first activation for one-shot faults', () => {
const result = normalizeFaultInjectionSpec({ kind: 'disk-full', enabled: true })
expect(result.ok).toBe(true)
if (result.ok) {
expect(shouldInjectFault(result.value, 0)).toBe(true)
expect(shouldInjectFault(result.value, 1)).toBe(false)
}
})

it.each([
[{ kind: 'unknown', enabled: true }, 'invalid-kind'],
[{ kind: 'http-429', enabled: 'yes' }, 'invalid-enabled'],
[{ kind: 'http-429', enabled: true, once: 'yes' }, 'invalid-once'],
[{ kind: 'http-429', enabled: true, delayMs: 60_001 }, 'invalid-delay'],
[{ kind: 'http-429', enabled: true, debug: true }, 'unknown-field']
])('rejects malformed specs %#', (input, error) => {
expect(normalizeFaultInjectionSpec(input)).toEqual({ ok: false, error })
})

it('fails closed for invalid activation counts or disabled specs', () => {
const result = normalizeFaultInjectionSpec({ kind: 'sqlite-busy', enabled: false })
expect(result.ok).toBe(true)
if (result.ok) {
expect(shouldInjectFault(result.value, 0)).toBe(false)
expect(shouldInjectFault({ ...result.value, enabled: true }, -1)).toBe(false)
expect(shouldInjectFault({ ...result.value, enabled: true }, Number.NaN)).toBe(false)
expect(shouldInjectFault({ kind: 'unknown', enabled: true, once: false, delayMs: 0 }, 0)).toBe(false)
}
})
})
Loading