582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
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<Record<string, string>> = {},
|
|
) => ({
|
|
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" });
|
|
});
|
|
});
|