diff --git a/kun/src/contracts/fault-injection.ts b/kun/src/contracts/fault-injection.ts new file mode 100644 index 000000000..1e65088ca --- /dev/null +++ b/kun/src/contracts/fault-injection.ts @@ -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 { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys) + return Object.keys(value).every((key) => allowed.has(key)) +} diff --git a/kun/tests/fault-injection.test.ts b/kun/tests/fault-injection.test.ts new file mode 100644 index 000000000..078880c40 --- /dev/null +++ b/kun/tests/fault-injection.test.ts @@ -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) + } + }) +})