From dc0c2e5ac31387138ec47b027bac6a4cbf007ba0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:17:14 +0000 Subject: [PATCH] feat(process): add bounded replay state machines --- src/domain/replayMachine.ts | 376 +++++++++++++++++++ src/domain/replayMachineRuntime.ts | 318 ++++++++++++++++ tests/replayMachine.test.ts | 244 ++++++++++++ tests/replayMachineRuntime.test.ts | 581 +++++++++++++++++++++++++++++ 4 files changed, 1519 insertions(+) create mode 100644 src/domain/replayMachine.ts create mode 100644 src/domain/replayMachineRuntime.ts create mode 100644 tests/replayMachine.test.ts create mode 100644 tests/replayMachineRuntime.test.ts diff --git a/src/domain/replayMachine.ts b/src/domain/replayMachine.ts new file mode 100644 index 000000000..d104be3f4 --- /dev/null +++ b/src/domain/replayMachine.ts @@ -0,0 +1,376 @@ +import { MultiDirectedGraph } from "graphology"; +import { singleSource } from "graphology-shortest-path"; +import canonicalize from "canonicalize"; +import { z } from "zod"; + +const replayIdentifierSchema = z + .string() + .regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u); +const replayPathSchema = z + .array(z.union([z.string().max(256), z.number().int().nonnegative()])) + .max(32); +const sensitiveHeaderNames = new Set([ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", +]); +const replayHeaderMatchersSchema = z + .record(z.string().min(1).max(256), z.string().max(8_192)) + .superRefine((headers, context) => { + const names = Object.keys(headers).map((name) => name.toLowerCase()); + if (new Set(names).size !== names.length) + context.addIssue({ + code: "custom", + message: "replay header matchers must be unique ignoring case", + input: headers, + }); + if (names.some((name) => sensitiveHeaderNames.has(name))) + context.addIssue({ + code: "custom", + message: + "replay header matchers cannot persist authorization or cookie values; capture and guard through a secret alias instead", + input: headers, + }); + }) + .transform((headers) => + Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ), + ); + +const replayValueSourceSchema = z.discriminatedUnion("source", [ + z.strictObject({ + source: z.literal("request_header"), + name: z + .string() + .min(1) + .transform((name) => name.toLowerCase()), + }), + z.strictObject({ source: z.literal("request_json"), path: replayPathSchema }), + z.strictObject({ + source: z.literal("websocket_json"), + path: replayPathSchema, + }), + z.strictObject({ source: z.literal("action_json"), path: replayPathSchema }), +]); + +const replayTriggerSchema = z.discriminatedUnion("protocol", [ + z.strictObject({ + protocol: z.literal("http"), + method: z + .string() + .min(1) + .max(16) + .transform((method) => method.toUpperCase()), + path: z.string().startsWith("/"), + headers: replayHeaderMatchersSchema.default({}), + body: z.string().max(1_000_000).nullable().default(null), + }), + z.strictObject({ + protocol: z.literal("websocket_connect"), + path: z.string().startsWith("/"), + headers: replayHeaderMatchersSchema.default({}), + }), + z.strictObject({ + protocol: z.literal("websocket_message"), + path: z.string().startsWith("/"), + body: z.string().max(1_000_000).nullable().default(null), + }), +]); + +const replayActionSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("http_response"), + status: z.number().int().min(100).max(599), + headers: z.record(z.string(), z.string()).default({}), + body: z.string().max(1_000_000), + }), + z.strictObject({ + type: z.literal("websocket_send"), + data: z.string().max(1_000_000), + }), + z.strictObject({ type: z.literal("disconnect") }), + z.strictObject({ + type: z.literal("delay"), + duration_ms: z.number().int().nonnegative().max(30_000), + }), +]); + +const replayTransitionSchema = z.strictObject({ + id: replayIdentifierSchema, + from: replayIdentifierSchema, + to: replayIdentifierSchema, + priority: z.number().int().min(0).max(1_000).default(100), + trigger: replayTriggerSchema, + guards: z + .array( + z.strictObject({ + variable: replayIdentifierSchema, + value: replayValueSourceSchema, + }), + ) + .max(32) + .default([]), + captures: z + .array( + z.strictObject({ + variable: replayIdentifierSchema, + value: replayValueSourceSchema, + sensitive: z.boolean().default(false), + }), + ) + .max(32) + .default([]), + actions: z.array(replayActionSchema).min(1).max(32), + max_uses: z.number().int().min(1).max(10_000), +}); + +const transitionSignature = ( + transition: z.infer, +): string => { + const signature = canonicalize({ + from: transition.from, + priority: transition.priority, + trigger: transition.trigger, + }); + if (signature === undefined) + throw new TypeError("Replay transition signature is not canonical JSON"); + return signature; +}; + +const replayMachineBaseSchema = z.strictObject({ + initial_state: replayIdentifierSchema, + states: z + .array( + z.strictObject({ + name: replayIdentifierSchema, + terminal: z.boolean().default(false), + max_visits: z.number().int().min(1).max(100_000).default(100_000), + }), + ) + .min(1) + .max(256), + transitions: z.array(replayTransitionSchema).min(1).max(2_000), + max_transitions: z.number().int().min(1).max(100_000), + limits: z + .strictObject({ + connections: z.number().int().min(1).max(1_000).default(100), + messages: z.number().int().min(1).max(100_000).default(10_000), + bytes: z.number().int().min(1).max(10_000_000).default(1_000_000), + duration_ms: z.number().int().min(1).max(300_000).default(30_000), + }) + .default({ + connections: 100, + messages: 10_000, + bytes: 1_000_000, + duration_ms: 30_000, + }), +}); +type ReplayMachineInput = z.infer; +type ReplayMachineContext = z.core.$RefinementCtx; + +const validateReplayIdentities = ( + machine: ReplayMachineInput, + context: ReplayMachineContext, +): ReadonlySet => { + const names = machine.states.map(({ name }) => name); + const nameSet = new Set(names); + if (nameSet.size !== names.length) + context.addIssue({ + code: "custom", + message: "replay machine state names must be unique", + path: ["states"], + input: machine, + }); + if (!nameSet.has(machine.initial_state)) + context.addIssue({ + code: "custom", + message: "replay machine initial state is not declared", + path: ["initial_state"], + input: machine, + }); + const transitionIds = machine.transitions.map(({ id }) => id); + if (new Set(transitionIds).size !== transitionIds.length) + context.addIssue({ + code: "custom", + message: "replay machine transition IDs must be unique", + path: ["transitions"], + input: machine, + }); + const signatures = machine.transitions.map(transitionSignature); + if (new Set(signatures).size !== signatures.length) + context.addIssue({ + code: "custom", + message: + "replay machine transitions with the same state, trigger, and priority are ambiguous", + path: ["transitions"], + input: machine, + }); + return nameSet; +}; + +const validateReplayVariables = ( + machine: ReplayMachineInput, + context: ReplayMachineContext, +): void => { + const capturedVariables = new Map(); + for (const [transitionIndex, transition] of machine.transitions.entries()) { + for (const capture of transition.captures) { + const sensitivity = capturedVariables.get(capture.variable); + if (sensitivity !== undefined && sensitivity !== capture.sensitive) + context.addIssue({ + code: "custom", + message: + "a replay variable cannot change secret classification between captures", + path: ["transitions", transitionIndex, "captures"], + input: machine, + }); + capturedVariables.set(capture.variable, capture.sensitive); + } + } + + const availableByState = new Map>([ + [machine.initial_state, new Set()], + ]); + let changed = true; + while (changed) { + changed = false; + for (const transition of machine.transitions) { + const available = availableByState.get(transition.from); + if ( + available === undefined || + transition.guards.some(({ variable }) => !available.has(variable)) + ) + continue; + const nextAvailable = new Set(available); + for (const { variable } of transition.captures) + nextAvailable.add(variable); + const existing = availableByState.get(transition.to); + if (existing === undefined) { + availableByState.set(transition.to, nextAvailable); + changed = true; + continue; + } + for (const variable of nextAvailable) + if (!existing.has(variable)) { + existing.add(variable); + changed = true; + } + } + } + + for (const [transitionIndex, transition] of machine.transitions.entries()) + for (const [guardIndex, guard] of transition.guards.entries()) + if (!availableByState.get(transition.from)?.has(guard.variable)) + context.addIssue({ + code: "custom", + message: + "replay guard variable is not captured on an executable path before use", + path: ["transitions", transitionIndex, "guards", guardIndex], + input: machine, + }); +}; + +const validateReplayActions = ( + machine: ReplayMachineInput, + context: ReplayMachineContext, +): void => { + for (const [index, transition] of machine.transitions.entries()) { + const httpResponses = transition.actions.filter( + ({ type }) => type === "http_response", + ).length; + const websocketSends = transition.actions.some( + ({ type }) => type === "websocket_send", + ); + const invalid = + transition.trigger.protocol === "http" + ? httpResponses !== 1 || websocketSends + : httpResponses !== 0; + if (invalid) + context.addIssue({ + code: "custom", + message: + "replay actions must match the transition protocol and HTTP transitions require exactly one response", + path: ["transitions", index, "actions"], + input: machine, + }); + } +}; + +const validateReplayGraph = ( + machine: ReplayMachineInput, + names: ReadonlySet, + context: ReplayMachineContext, +): void => { + const graph = new MultiDirectedGraph(); + for (const name of names) graph.addNode(name); + for (const [index, transition] of machine.transitions.entries()) { + if (!names.has(transition.from) || !names.has(transition.to)) { + context.addIssue({ + code: "custom", + message: "replay machine transition references an unknown state", + path: ["transitions", index], + input: machine, + }); + continue; + } + graph.addDirectedEdgeWithKey( + `${transition.id}:${String(index)}`, + transition.from, + transition.to, + ); + } + if (!graph.hasNode(machine.initial_state)) return; + if (!machine.states.some(({ terminal }) => terminal)) + context.addIssue({ + code: "custom", + message: "replay machine must declare at least one terminal state", + path: ["states"], + input: machine, + }); + const reachable = new Set( + Object.keys(singleSource(graph, machine.initial_state)), + ); + reachable.add(machine.initial_state); + for (const [index, state] of machine.states.entries()) { + if (!reachable.has(state.name)) + context.addIssue({ + code: "custom", + message: "replay machine state is unreachable from the initial state", + path: ["states", index], + input: machine, + }); + if (state.terminal && graph.outDegree(state.name) > 0) + context.addIssue({ + code: "custom", + message: + "terminal replay machine state cannot have outgoing transitions", + path: ["states", index], + input: machine, + }); + if (!state.terminal && graph.outDegree(state.name) === 0) + context.addIssue({ + code: "custom", + message: "non-terminal replay machine state has no outgoing transition", + path: ["states", index], + input: machine, + }); + } +}; + +/** Portable finite replay machine with no executable callbacks. */ +export const replayMachineSchema = z + .object(replayMachineBaseSchema.shape) + .strict() + .superRefine((machine, context) => { + const names = validateReplayIdentities(machine, context); + validateReplayVariables(machine, context); + validateReplayActions(machine, context); + validateReplayGraph(machine, names, context); + }); + +export type ReplayMachine = z.infer; diff --git a/src/domain/replayMachineRuntime.ts b/src/domain/replayMachineRuntime.ts new file mode 100644 index 000000000..178f62a49 --- /dev/null +++ b/src/domain/replayMachineRuntime.ts @@ -0,0 +1,318 @@ +import canonicalize from "canonicalize"; + +import type { ReplayMachine } from "./replayMachine.js"; + +type ReplayTransition = ReplayMachine["transitions"][number]; +type ReplayValueSource = ReplayTransition["captures"][number]["value"]; + +/** One bounded inbound protocol event offered to a replay machine. */ +export interface ReplayMachineEvent { + readonly protocol: "http" | "websocket_connect" | "websocket_message"; + readonly connection: "not_applicable" | "initial" | "reconnect"; + readonly at_ms: number; + readonly method: string | null; + readonly path: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Persistable transition journal entry containing aliases, never secrets. */ +export interface ReplayTransitionRecord { + readonly sequence: number; + readonly transition_id: string; + readonly state_before: string; + readonly state_after: string; + readonly sensitive_aliases: readonly string[]; +} + +/** Result of dispatching one event through a replay machine. */ +export type ReplayMachineDecision = + | { + readonly outcome: "matched"; + readonly actions: ReplayTransition["actions"]; + readonly transition: ReplayTransitionRecord; + } + | { + readonly outcome: + | "unmatched" + | "invalid_state" + | "guard_failed" + | "transition_exhausted" + | "invalid_capture" + | "unexpected_reconnect" + | "limit_exhausted"; + readonly actions: readonly []; + readonly transition: null; + }; + +const parseJson = (value: string): unknown => { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +}; + +const atPath = ( + value: unknown, + path: readonly (string | number)[], +): unknown => { + let current = value; + for (const segment of path) { + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + continue; + } + if ( + typeof current !== "object" || + current === null || + !Object.hasOwn(current, segment) + ) + return undefined; + current = (current as Readonly>)[segment]; + } + return current; +}; + +const actionJsonBody = (transition: ReplayTransition): string | undefined => { + for (const action of transition.actions) + if (action.type === "http_response") return action.body; + else if (action.type === "websocket_send") return action.data; + return undefined; +}; + +const readValue = ( + source: ReplayValueSource, + event: ReplayMachineEvent, + transition: ReplayTransition, +): unknown => { + switch (source.source) { + case "request_header": + return event.headers[source.name.toLowerCase()]; + case "request_json": + return atPath(parseJson(event.body), source.path); + case "websocket_json": + return atPath(parseJson(event.body), source.path); + case "action_json": { + const body = actionJsonBody(transition); + return body === undefined + ? undefined + : atPath(parseJson(body), source.path); + } + } +}; + +const triggerMatches = ( + transition: ReplayTransition, + event: ReplayMachineEvent, +): boolean => { + const trigger = transition.trigger; + if (trigger.protocol !== event.protocol || trigger.path !== event.path) + return false; + if ( + "headers" in trigger && + Object.entries(trigger.headers).some( + ([name, value]) => event.headers[name.toLowerCase()] !== value, + ) + ) + return false; + if (trigger.protocol === "http") + return ( + trigger.method === event.method && + (trigger.body === null || trigger.body === event.body) + ); + if (trigger.protocol === "websocket_message") + return trigger.body === null || trigger.body === event.body; + return true; +}; + +const isJsonCompatible = (value: unknown): boolean => { + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonCompatible); + if (typeof value !== "object") return false; + return Object.values(value).every(isJsonCompatible); +}; + +const replayValuesEqual = (left: unknown, right: unknown): boolean => + isJsonCompatible(left) && + isJsonCompatible(right) && + (Object.is(left, right) || canonicalize(left) === canonicalize(right)); + +/** Stateful evaluator for one validated replay-machine instance. */ +export class ReplayMachineRuntime { + readonly #variables = new Map(); + readonly #sensitiveValues = new Map>(); + readonly #uses = new Map(); + readonly #stateVisits = new Map(); + readonly #timeline: ReplayTransitionRecord[] = []; + #state: string; + #connections = 0; + #messages = 0; + #bytes = 0; + #lastAtMs = 0; + + constructor(readonly machine: ReplayMachine) { + this.#state = machine.initial_state; + this.#stateVisits.set(machine.initial_state, 1); + } + + /** Current machine state after all accepted transitions. */ + get state(): string { + return this.#state; + } + + /** Ordered, secret-free transition records. */ + get timeline(): readonly ReplayTransitionRecord[] { + return this.#timeline; + } + + /** Replace captured secret values with stable aliases before persistence. */ + redact(value: string): string { + let redacted = value; + const secrets = [...this.#sensitiveValues] + .flatMap(([variable, values]) => + [...values].map((secret) => ({ variable, secret })), + ) + .sort( + (left, right) => + right.secret.length - left.secret.length || + left.variable.localeCompare(right.variable), + ); + for (const { variable, secret } of secrets) + redacted = redacted.replaceAll(secret, ``); + return redacted; + } + + /** Evaluate and, when admitted, commit one protocol event. */ + dispatch(event: ReplayMachineEvent): ReplayMachineDecision { + const normalizedEvent = { + ...event, + headers: Object.fromEntries( + Object.entries(event.headers).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ), + }; + if (!this.#admitEvent(normalizedEvent)) + return this.#refusal("limit_exhausted"); + const matching = this.machine.transitions + .map((transition, declarationOrder) => ({ + transition, + declarationOrder, + })) + .filter(({ transition }) => triggerMatches(transition, normalizedEvent)); + const eligible = matching + .filter(({ transition }) => transition.from === this.#state) + .sort( + (left, right) => + left.transition.priority - right.transition.priority || + left.declarationOrder - right.declarationOrder, + ); + if (eligible.length === 0) + return this.#refusal( + matching.length === 0 + ? "unmatched" + : normalizedEvent.protocol === "websocket_connect" && + normalizedEvent.connection === "reconnect" + ? "unexpected_reconnect" + : "invalid_state", + ); + const guarded = eligible.filter(({ transition }) => + transition.guards.every( + (guard) => + this.#variables.has(guard.variable) && + replayValuesEqual( + this.#variables.get(guard.variable), + readValue(guard.value, normalizedEvent, transition), + ), + ), + ); + if (guarded.length === 0) return this.#refusal("guard_failed"); + if (this.#timeline.length >= this.machine.max_transitions) + return this.#refusal("transition_exhausted"); + const usable = guarded.find( + ({ transition }) => + (this.#uses.get(transition.id) ?? 0) < transition.max_uses, + ); + if (usable === undefined) return this.#refusal("transition_exhausted"); + const transition = usable.transition; + const used = this.#uses.get(transition.id) ?? 0; + const nextState = this.machine.states.find( + ({ name }) => name === transition.to, + ); + const nextVisits = (this.#stateVisits.get(transition.to) ?? 0) + 1; + if (nextState === undefined || nextVisits > nextState.max_visits) + return this.#refusal("limit_exhausted"); + const captures = transition.captures.map((capture) => ({ + ...capture, + captured: readValue(capture.value, normalizedEvent, transition), + })); + if ( + captures.some( + ({ captured, sensitive }) => + !isJsonCompatible(captured) || + (sensitive && typeof captured !== "string"), + ) + ) + return this.#refusal("invalid_capture"); + for (const capture of captures) { + this.#variables.set(capture.variable, capture.captured); + if (capture.sensitive && typeof capture.captured === "string") { + const values = this.#sensitiveValues.get(capture.variable) ?? new Set(); + if (capture.captured.length > 0) values.add(capture.captured); + this.#sensitiveValues.set(capture.variable, values); + } + } + const record = { + sequence: this.#timeline.length, + transition_id: transition.id, + state_before: this.#state, + state_after: transition.to, + sensitive_aliases: captures + .filter(({ sensitive }) => sensitive) + .map(({ variable }) => variable) + .sort(), + } satisfies ReplayTransitionRecord; + this.#uses.set(transition.id, used + 1); + this.#stateVisits.set(transition.to, nextVisits); + this.#state = transition.to; + this.#timeline.push(record); + return { + outcome: "matched", + actions: transition.actions, + transition: record, + }; + } + + #admitEvent(event: ReplayMachineEvent): boolean { + const connections = + this.#connections + (event.protocol === "websocket_connect" ? 1 : 0); + const messages = + this.#messages + (event.protocol === "websocket_message" ? 1 : 0); + const bytes = this.#bytes + Buffer.byteLength(event.body); + if ( + !Number.isSafeInteger(event.at_ms) || + event.at_ms < this.#lastAtMs || + event.at_ms > this.machine.limits.duration_ms || + connections > this.machine.limits.connections || + messages > this.machine.limits.messages || + bytes > this.machine.limits.bytes + ) + return false; + this.#connections = connections; + this.#messages = messages; + this.#bytes = bytes; + this.#lastAtMs = event.at_ms; + return true; + } + + #refusal( + outcome: Exclude, + ): ReplayMachineDecision { + return { outcome, actions: [], transition: null }; + } +} diff --git a/tests/replayMachine.test.ts b/tests/replayMachine.test.ts new file mode 100644 index 000000000..663d54a71 --- /dev/null +++ b/tests/replayMachine.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; + +import { replayMachineSchema } from "../src/domain/replayMachine.js"; + +const loginMachine = () => ({ + initial_state: "waiting_for_login", + states: [ + { name: "waiting_for_login" }, + { name: "waiting_for_api" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "login", + from: "waiting_for_login", + to: "waiting_for_api", + priority: 10, + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "token", + value: { source: "request_json", path: ["token"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, headers: {}, body: "" }], + max_uses: 1, + }, + { + id: "api", + from: "waiting_for_api", + to: "complete", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/api" }, + guards: [ + { + variable: "token", + value: { source: "request_header", name: "authorization" }, + }, + ], + actions: [ + { + type: "http_response", + status: 200, + headers: {}, + body: "done", + }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, +}); + +describe("replay machine", () => { + it("accepts a bounded, reachable, data-only login machine", () => { + expect(replayMachineSchema.parse(loginMachine())).toMatchObject({ + initial_state: "waiting_for_login", + max_transitions: 2, + }); + }); + + it.each([ + [ + "unreachable state", + (machine: ReturnType) => + machine.states.push({ name: "orphan", terminal: false }), + "unreachable", + ], + [ + "ambiguous transition", + (machine: ReturnType) => { + const transition = machine.transitions[0]; + if (transition === undefined) throw new TypeError("missing transition"); + machine.transitions.push({ ...transition, id: "login_2" }); + }, + "ambiguous", + ], + [ + "outgoing terminal transition", + (machine: ReturnType) => { + const transition = machine.transitions[0]; + if (transition === undefined) throw new TypeError("missing transition"); + machine.transitions.push({ + ...transition, + id: "restart", + from: "complete", + to: "waiting_for_login", + trigger: { protocol: "http", method: "POST", path: "/restart" }, + }); + }, + "terminal", + ], + [ + "unknown guard variable", + (machine: ReturnType) => { + const guard = machine.transitions[1]?.guards?.[0]; + if (guard === undefined) throw new TypeError("missing guard"); + guard.variable = "missing"; + }, + "executable path before use", + ], + [ + "missing terminal state", + (machine: ReturnType) => { + const state = machine.states[2]; + if (state === undefined) throw new TypeError("missing state"); + state.terminal = false; + }, + "terminal state", + ], + ])("rejects %s", (_name, mutate, message) => { + const machine = loginMachine(); + mutate(machine); + expect(() => replayMachineSchema.parse(machine)).toThrow(message); + }); + + it("canonicalizes methods and headers while refusing persisted credential matchers", () => { + const canonical = { + ...loginMachine(), + transitions: loginMachine().transitions.map((transition, index) => + index === 0 + ? { + ...transition, + trigger: { + protocol: "http", + method: "post", + path: "/login", + headers: { "X-Request-ID": "fixture" }, + }, + } + : transition, + ), + }; + expect( + replayMachineSchema.parse(canonical).transitions[0]?.trigger, + ).toMatchObject({ + method: "POST", + headers: { "x-request-id": "fixture" }, + }); + const credentialMatcher = { + ...loginMachine(), + transitions: loginMachine().transitions.map((transition, index) => + index === 0 + ? { + ...transition, + trigger: { + protocol: "http", + method: "POST", + path: "/login", + headers: { Authorization: "test-auth-token" }, + }, + } + : transition, + ), + }; + expect(() => replayMachineSchema.parse(credentialMatcher)).toThrow( + /secret alias/u, + ); + }); + + it("rejects unknown fields at nested machine boundaries", () => { + const candidate = loginMachine(); + const action = candidate.transitions[0]?.actions[0]; + if (action === undefined) throw new TypeError("missing action"); + + expect(() => + replayMachineSchema.parse({ + ...candidate, + transitions: [ + { + ...candidate.transitions[0], + actions: [{ ...action, unexpected: true }], + }, + candidate.transitions[1], + ], + }), + ).toThrow(/Unrecognized key/u); + }); + + it("reports duplicate transition IDs as structured validation", () => { + const candidate = loginMachine(); + const first = candidate.transitions[0]; + const second = candidate.transitions[1]; + if (first === undefined || second === undefined) + throw new TypeError("missing transition"); + + expect( + replayMachineSchema.safeParse({ + ...candidate, + transitions: [first, { ...second, id: first.id }], + }), + ).toMatchObject({ success: false }); + }); + + it("rejects guards whose capture is not available before the transition", () => { + const candidate = loginMachine(); + const first = candidate.transitions[0]; + if (first === undefined) throw new TypeError("missing transition"); + first.guards = [ + { + variable: "token", + value: { source: "request_header", name: "authorization" }, + }, + ]; + + expect(() => replayMachineSchema.parse(candidate)).toThrow( + /executable path before use/u, + ); + }); + + it("rejects ambiguous header triggers regardless of key order", () => { + const candidate = loginMachine(); + const first = candidate.transitions[0]; + if (first === undefined) throw new TypeError("missing transition"); + const withHeaders = { + ...first, + trigger: { + protocol: "http" as const, + method: "POST", + path: "/login", + headers: { accept: "json", "x-mode": "test" }, + }, + }; + + expect(() => + replayMachineSchema.parse({ + ...candidate, + transitions: [ + withHeaders, + { + ...withHeaders, + id: "login_duplicate", + trigger: { + ...withHeaders.trigger, + headers: { "x-mode": "test", accept: "json" }, + }, + }, + candidate.transitions[1], + ], + }), + ).toThrow(/ambiguous/u); + }); +}); diff --git a/tests/replayMachineRuntime.test.ts b/tests/replayMachineRuntime.test.ts new file mode 100644 index 000000000..500c69eae --- /dev/null +++ b/tests/replayMachineRuntime.test.ts @@ -0,0 +1,581 @@ +import { describe, expect, it } from "vitest"; + +import { replayMachineSchema } from "../src/domain/replayMachine.js"; +import { ReplayMachineRuntime } from "../src/domain/replayMachineRuntime.js"; + +const machine = replayMachineSchema.parse({ + initial_state: "login", + states: [ + { name: "login" }, + { name: "api" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "capture_token", + from: "login", + to: "api", + priority: 10, + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "session_value", + value: { source: "request_json", path: ["session_value"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, headers: {}, body: "" }], + max_uses: 1, + }, + { + id: "authorize_api", + from: "api", + to: "complete", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/api" }, + guards: [ + { + variable: "session_value", + value: { source: "request_header", name: "authorization" }, + }, + ], + actions: [ + { type: "http_response", status: 200, headers: {}, body: "done" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, +}); + +const event = ( + method: string, + path: string, + body = "", + headers: Readonly> = {}, +) => ({ + protocol: "http" as const, + connection: "not_applicable" as const, + at_ms: 0, + method, + path, + body, + headers, +}); + +describe("replay machine runtime", () => { + it("captures secret values in memory and journals aliases only", () => { + const runtime = new ReplayMachineRuntime(machine); + expect( + runtime.dispatch( + event("POST", "/login", '{"session_value":"test-auth-token"}'), + ), + ).toMatchObject({ + outcome: "matched", + transition: { + transition_id: "capture_token", + sensitive_aliases: ["session_value"], + }, + }); + const expectedRedaction = ["Bearer <", "secret", ":session_value>"].join( + "", + ); + expect(runtime.redact("Bearer test-auth-token")).toBe(expectedRedaction); + expect( + runtime.dispatch( + event("GET", "/api", "", { Authorization: "test-auth-token" }), + ), + ).toMatchObject({ outcome: "matched" }); + expect(JSON.stringify(runtime.timeline)).not.toContain("test-auth-token"); + expect(runtime.state).toBe("complete"); + }); + + it("classifies wrong-state, guard, capture, and exhaustion failures", () => { + expect( + new ReplayMachineRuntime(machine).dispatch(event("GET", "/api")), + ).toMatchObject({ outcome: "invalid_state" }); + expect( + new ReplayMachineRuntime(machine).dispatch(event("POST", "/login", "{}")), + ).toMatchObject({ outcome: "invalid_capture" }); + const runtime = new ReplayMachineRuntime(machine); + runtime.dispatch( + event("POST", "/login", '{"session_value":"test-auth-token"}'), + ); + expect( + runtime.dispatch( + event("GET", "/api", "", { + authorization: "test-token-placeholder", + }), + ), + ).toMatchObject({ + outcome: "guard_failed", + }); + const bounded = new ReplayMachineRuntime( + replayMachineSchema.parse({ + initial_state: "active", + states: [{ name: "active" }, { name: "done", terminal: true }], + transitions: [ + { + id: "once", + from: "active", + to: "active", + trigger: { protocol: "http", method: "GET", path: "/once" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }), + ); + expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ + outcome: "matched", + }); + expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ + outcome: "transition_exhausted", + }); + }); + + it("matches bounded headers and bodies and distinguishes wrong-state reconnects", () => { + const runtime = new ReplayMachineRuntime( + replayMachineSchema.parse({ + initial_state: "connected", + states: [{ name: "connected" }, { name: "done", terminal: true }], + transitions: [ + { + id: "finish", + from: "connected", + to: "done", + trigger: { + protocol: "http", + method: "POST", + path: "/finish", + headers: { "content-type": "application/json" }, + body: '{"done":true}', + }, + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "initial_socket", + from: "connected", + to: "connected", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "disconnect" }], + max_uses: 1, + }, + ], + max_transitions: 2, + }), + ); + expect(runtime.dispatch(event("POST", "/finish", "wrong"))).toMatchObject({ + outcome: "unmatched", + }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "initial", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "reconnect", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "transition_exhausted" }); + expect( + runtime.dispatch( + event("POST", "/finish", '{"done":true}', { + "content-type": "application/json", + }), + ), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "reconnect", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "unexpected_reconnect" }); + }); + + it("enforces state, connection, message, byte, and duration limits", () => { + const limitedMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 1 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "loop", + from: "active", + to: "active", + trigger: { protocol: "websocket_message", path: "/ws" }, + actions: [{ type: "websocket_send", data: "ack" }], + max_uses: 2, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + ], + max_transitions: 3, + limits: { connections: 1, messages: 1, bytes: 100, duration_ms: 10 }, + }); + const stateLimited = new ReplayMachineRuntime(limitedMachine); + expect( + stateLimited.dispatch({ + protocol: "websocket_message", + connection: "not_applicable", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "one", + }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const messageLimited = new ReplayMachineRuntime( + replayMachineSchema.parse({ + ...limitedMachine, + states: limitedMachine.states.map((state) => + state.name === "active" ? { ...state, max_visits: 3 } : state, + ), + }), + ); + const message = { + protocol: "websocket_message" as const, + connection: "not_applicable" as const, + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "one", + }; + expect(messageLimited.dispatch(message)).toMatchObject({ + outcome: "matched", + }); + expect(messageLimited.dispatch(message)).toMatchObject({ + outcome: "limit_exhausted", + }); + const durationLimited = new ReplayMachineRuntime(limitedMachine); + expect( + durationLimited.dispatch({ ...event("GET", "/done"), at_ms: 11 }), + ).toMatchObject({ outcome: "limit_exhausted" }); + expect( + new ReplayMachineRuntime(limitedMachine).dispatch({ + ...event("GET", "/done"), + at_ms: Number.NaN, + }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const monotonic = new ReplayMachineRuntime(limitedMachine); + expect( + monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 5 }), + ).toMatchObject({ outcome: "unmatched" }); + expect( + monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 4 }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const byteLimited = new ReplayMachineRuntime( + replayMachineSchema.parse({ + ...limitedMachine, + limits: { ...limitedMachine.limits, bytes: 4 }, + }), + ); + expect( + byteLimited.dispatch(event("GET", "/missing", "12345")), + ).toMatchObject({ outcome: "limit_exhausted" }); + const connectionLimited = new ReplayMachineRuntime(limitedMachine); + const connect = { + protocol: "websocket_connect" as const, + connection: "initial" as const, + at_ms: 0, + method: null, + path: "/unknown", + headers: {}, + body: "", + }; + expect(connectionLimited.dispatch(connect)).toMatchObject({ + outcome: "unmatched", + }); + expect( + connectionLimited.dispatch({ ...connect, connection: "reconnect" }), + ).toMatchObject({ outcome: "limit_exhausted" }); + }); + + it("compares captured JSON values canonically", () => { + const objectMachine = replayMachineSchema.parse({ + initial_state: "capture", + states: [ + { name: "capture" }, + { name: "guard" }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "capture_profile", + from: "capture", + to: "guard", + trigger: { protocol: "http", method: "POST", path: "/capture" }, + captures: [ + { + variable: "profile", + value: { source: "request_json", path: ["profile"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "guard_profile", + from: "guard", + to: "done", + trigger: { protocol: "http", method: "POST", path: "/guard" }, + guards: [ + { + variable: "profile", + value: { source: "request_json", path: ["profile"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + const runtime = new ReplayMachineRuntime(objectMachine); + + expect( + runtime.dispatch( + event("POST", "/capture", '{"profile":{"name":"A","id":1}}'), + ), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch( + event("POST", "/guard", '{"profile":{"id":1,"name":"A"}}'), + ), + ).toMatchObject({ outcome: "matched" }); + }); + + it("does not traverse inherited properties in JSON capture paths", () => { + const inheritedPathMachine = replayMachineSchema.parse({ + ...machine, + transitions: machine.transitions.map((transition, index) => + index === 0 + ? { + ...transition, + captures: [ + { + variable: "session_value", + value: { + source: "request_json" as const, + path: ["__proto__", "toString"], + }, + sensitive: true, + }, + ], + } + : transition, + ), + }); + + expect( + new ReplayMachineRuntime(inheritedPathMachine).dispatch( + event("POST", "/login", "{}"), + ), + ).toMatchObject({ outcome: "invalid_capture" }); + }); + + it("rejects non-string sensitive captures and retains rotated values", () => { + expect( + new ReplayMachineRuntime(machine).dispatch( + event("POST", "/login", '{"session_value":1234}'), + ), + ).toMatchObject({ outcome: "invalid_capture" }); + + const rotatingMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 3 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "rotate", + from: "active", + to: "active", + trigger: { protocol: "http", method: "POST", path: "/rotate" }, + captures: [ + { + variable: "session_value", + value: { source: "request_json", path: ["value"] }, + sensitive: true, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 2, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 3, + }); + const rotating = new ReplayMachineRuntime(rotatingMachine); + rotating.dispatch(event("POST", "/rotate", '{"value":"alpha"}')); + rotating.dispatch(event("POST", "/rotate", '{"value":"beta"}')); + const alias = ["<", "secret", ":session_value>"].join(""); + + expect(rotating.redact("alpha beta")).toBe(`${alias} ${alias}`); + }); + + it("falls back to a lower-priority transition after a preferred one is exhausted", () => { + const fallbackMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 2 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "preferred", + from: "active", + to: "active", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/next" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "again" }, + ], + max_uses: 1, + }, + { + id: "fallback", + from: "active", + to: "done", + priority: 20, + trigger: { protocol: "http", method: "GET", path: "/next" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "done" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + const runtime = new ReplayMachineRuntime(fallbackMachine); + + expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ + outcome: "matched", + transition: { transition_id: "preferred" }, + }); + expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ + outcome: "matched", + transition: { transition_id: "fallback" }, + }); + }); + + it("rejects non-finite parsed JSON captures and guard values", () => { + const jsonMachine = replayMachineSchema.parse({ + initial_state: "capture", + states: [ + { name: "capture" }, + { name: "guard" }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "capture_value", + from: "capture", + to: "guard", + trigger: { protocol: "http", method: "POST", path: "/capture" }, + captures: [ + { + variable: "value", + value: { source: "request_json", path: ["value"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "guard_value", + from: "guard", + to: "done", + trigger: { protocol: "http", method: "POST", path: "/guard" }, + guards: [ + { + variable: "value", + value: { source: "request_json", path: ["value"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + + expect( + new ReplayMachineRuntime(jsonMachine).dispatch( + event("POST", "/capture", '{"value":1e400}'), + ), + ).toMatchObject({ outcome: "invalid_capture" }); + + const guarded = new ReplayMachineRuntime(jsonMachine); + expect( + guarded.dispatch(event("POST", "/capture", '{"value":null}')), + ).toMatchObject({ outcome: "matched" }); + expect( + guarded.dispatch(event("POST", "/guard", '{"value":1e400}')), + ).toMatchObject({ outcome: "guard_failed" }); + }); +});