458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
replayMachineRunInputSchema,
|
|
runReplayMachine,
|
|
} from "../src/domain/replayMachineRun.js";
|
|
|
|
const loginRun = () =>
|
|
replayMachineRunInputSchema.parse({
|
|
machine: {
|
|
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: "token",
|
|
value: { source: "request_json", path: ["token"] },
|
|
sensitive: true,
|
|
},
|
|
],
|
|
actions: [
|
|
{
|
|
type: "http_response",
|
|
status: 204,
|
|
headers: { "x-token": "target-secret" },
|
|
body: "target-secret",
|
|
},
|
|
],
|
|
max_uses: 1,
|
|
},
|
|
{
|
|
id: "authorize",
|
|
from: "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,
|
|
limits: {
|
|
connections: 2,
|
|
messages: 4,
|
|
bytes: 1_000,
|
|
duration_ms: 100,
|
|
},
|
|
},
|
|
events: [
|
|
{
|
|
protocol: "http",
|
|
connection: "not_applicable",
|
|
at_ms: 1,
|
|
method: "post",
|
|
path: "/login",
|
|
headers: {},
|
|
body: '{"token":"target-secret"}',
|
|
},
|
|
{
|
|
protocol: "http",
|
|
connection: "not_applicable",
|
|
at_ms: 2,
|
|
method: "GET",
|
|
path: "/api",
|
|
headers: { Authorization: "target-secret" },
|
|
body: "",
|
|
},
|
|
],
|
|
});
|
|
|
|
const parseMachineRun = (machine: unknown, events: unknown[]) =>
|
|
replayMachineRunInputSchema.parse({ machine, events });
|
|
|
|
const httpEvent = (path: string, atMs = 0) => ({
|
|
protocol: "http" as const,
|
|
connection: "not_applicable" as const,
|
|
at_ms: atMs,
|
|
method: "GET",
|
|
path,
|
|
headers: {},
|
|
body: "",
|
|
});
|
|
|
|
describe("direct replay machine runner", () => {
|
|
it("returns actions, aliases, terminal state, and exact limit use", () => {
|
|
const result = runReplayMachine(loginRun());
|
|
|
|
expect(result).toMatchObject({
|
|
schema_version: 1,
|
|
initial_state: "login",
|
|
final_state: "complete",
|
|
terminal: true,
|
|
decisions: [
|
|
{ event_sequence: 0, outcome: "matched", transition_sequence: 0 },
|
|
{ event_sequence: 1, outcome: "matched", transition_sequence: 1 },
|
|
],
|
|
transition_journal: [
|
|
{
|
|
transition_id: "capture_token",
|
|
captured_aliases: [{ name: "token", sensitive: true }],
|
|
},
|
|
{
|
|
transition_id: "authorize",
|
|
captured_aliases: [],
|
|
},
|
|
],
|
|
transition_actions: [
|
|
{
|
|
transition_id: "capture_token",
|
|
actions: [
|
|
{
|
|
type: "http_response",
|
|
status: 204,
|
|
headers: { "x-token": "<secret:token>" },
|
|
body: "<secret:token>",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
transition_id: "authorize",
|
|
actions: [
|
|
{
|
|
type: "http_response",
|
|
status: 200,
|
|
headers: {},
|
|
body: "done",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
limits: {
|
|
configured: {
|
|
transitions: 2,
|
|
connections: 2,
|
|
messages: 4,
|
|
bytes: 1_000,
|
|
duration_ms: 100,
|
|
},
|
|
usage: {
|
|
offered_events: 2,
|
|
admitted_events: 2,
|
|
transitions: 2,
|
|
connections: 0,
|
|
messages: 0,
|
|
bytes: 25,
|
|
last_at_ms: 2,
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("target-secret");
|
|
});
|
|
|
|
it("classifies unmatched and guard-failed events without aborting", () => {
|
|
const input = loginRun();
|
|
const result = runReplayMachine({
|
|
...input,
|
|
events: [httpEvent("/missing"), input.events[0]!, httpEvent("/api", 2)],
|
|
});
|
|
|
|
expect(result.decisions.map(({ outcome }) => outcome)).toEqual([
|
|
"unmatched",
|
|
"matched",
|
|
"guard_failed",
|
|
]);
|
|
});
|
|
|
|
it("classifies exhausted transitions", () => {
|
|
const input = parseMachineRun(
|
|
{
|
|
initial_state: "active",
|
|
states: [
|
|
{ name: "active", max_visits: 3 },
|
|
{ name: "done", terminal: true },
|
|
],
|
|
transitions: [
|
|
{
|
|
id: "retry_once",
|
|
from: "active",
|
|
to: "active",
|
|
trigger: { protocol: "http", method: "GET", path: "/retry" },
|
|
actions: [{ type: "http_response", status: 200, body: "retry" }],
|
|
max_uses: 1,
|
|
},
|
|
{
|
|
id: "finish",
|
|
from: "active",
|
|
to: "done",
|
|
trigger: { protocol: "http", method: "GET", path: "/done" },
|
|
actions: [{ type: "http_response", status: 200, body: "done" }],
|
|
max_uses: 1,
|
|
},
|
|
],
|
|
max_transitions: 3,
|
|
},
|
|
[httpEvent("/retry"), httpEvent("/retry", 1)],
|
|
);
|
|
|
|
expect(
|
|
runReplayMachine(input).decisions.map(({ outcome }) => outcome),
|
|
).toEqual(["matched", "transition_exhausted"]);
|
|
});
|
|
|
|
it("classifies reconnects that arrive in the wrong state", () => {
|
|
const input = parseMachineRun(
|
|
{
|
|
initial_state: "connected",
|
|
states: [
|
|
{ name: "connected" },
|
|
{ name: "reconnecting" },
|
|
{ name: "done", terminal: true },
|
|
],
|
|
transitions: [
|
|
{
|
|
id: "disconnect",
|
|
from: "connected",
|
|
to: "reconnecting",
|
|
trigger: {
|
|
protocol: "websocket_message",
|
|
path: "/socket",
|
|
body: "disconnect",
|
|
},
|
|
actions: [{ type: "disconnect" }],
|
|
max_uses: 1,
|
|
},
|
|
{
|
|
id: "reconnect",
|
|
from: "reconnecting",
|
|
to: "done",
|
|
trigger: { protocol: "websocket_connect", path: "/socket" },
|
|
actions: [{ type: "websocket_send", data: "ready" }],
|
|
max_uses: 1,
|
|
},
|
|
],
|
|
max_transitions: 2,
|
|
},
|
|
[
|
|
{
|
|
protocol: "websocket_connect",
|
|
connection: "reconnect",
|
|
at_ms: 0,
|
|
method: null,
|
|
path: "/socket",
|
|
headers: {},
|
|
body: "",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(runReplayMachine(input).decisions[0]?.outcome).toBe(
|
|
"unexpected_reconnect",
|
|
);
|
|
});
|
|
|
|
it("rejects protocol-inconsistent events at the boundary", () => {
|
|
const input = loginRun();
|
|
expect(
|
|
replayMachineRunInputSchema.safeParse({
|
|
...input,
|
|
events: [{ ...httpEvent("/login"), method: null }],
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
replayMachineRunInputSchema.safeParse({
|
|
...input,
|
|
events: [
|
|
{
|
|
...httpEvent("/login"),
|
|
protocol: "websocket_connect",
|
|
connection: "reconnect",
|
|
},
|
|
],
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
replayMachineRunInputSchema.safeParse({
|
|
...input,
|
|
events: [
|
|
{
|
|
...httpEvent("/login"),
|
|
headers: { Authorization: "first", authorization: "second" },
|
|
},
|
|
],
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("reports limit refusals without committing rejected usage", () => {
|
|
const input = loginRun();
|
|
const result = runReplayMachine({
|
|
...input,
|
|
machine: {
|
|
...input.machine,
|
|
limits: { ...input.machine.limits, bytes: 1 },
|
|
},
|
|
events: [input.events[0]!],
|
|
});
|
|
|
|
expect(result.decisions).toEqual([
|
|
{
|
|
event_sequence: 0,
|
|
outcome: "limit_exhausted",
|
|
transition_sequence: null,
|
|
},
|
|
]);
|
|
expect(result.limits.usage).toMatchObject({
|
|
offered_events: 1,
|
|
admitted_events: 0,
|
|
transitions: 0,
|
|
bytes: 0,
|
|
});
|
|
});
|
|
|
|
it("records large repeated actions once instead of amplifying each match", () => {
|
|
const largeBody = "x".repeat(1_000_000);
|
|
const input = parseMachineRun(
|
|
{
|
|
initial_state: "active",
|
|
states: [
|
|
{ name: "active", max_visits: 11 },
|
|
{ name: "done", terminal: true },
|
|
],
|
|
transitions: [
|
|
{
|
|
id: "repeat",
|
|
from: "active",
|
|
to: "active",
|
|
trigger: { protocol: "http", method: "GET", path: "/repeat" },
|
|
actions: [{ type: "http_response", status: 200, body: largeBody }],
|
|
max_uses: 10,
|
|
},
|
|
{
|
|
id: "finish",
|
|
from: "active",
|
|
to: "done",
|
|
trigger: { protocol: "http", method: "GET", path: "/done" },
|
|
actions: [{ type: "http_response", status: 204, body: "" }],
|
|
max_uses: 1,
|
|
},
|
|
],
|
|
max_transitions: 11,
|
|
},
|
|
Array.from({ length: 10 }, (_, index) => httpEvent("/repeat", index)),
|
|
);
|
|
|
|
const result = runReplayMachine(input);
|
|
expect(result.transition_journal).toHaveLength(10);
|
|
expect(result.transition_actions).toHaveLength(1);
|
|
expect(JSON.stringify(result).length).toBeLessThan(1_100_000);
|
|
});
|
|
|
|
it("rejects aggregate action output above four MiB", () => {
|
|
const largeBody = "x".repeat(1_000_000);
|
|
const parsed = replayMachineRunInputSchema.safeParse({
|
|
machine: {
|
|
initial_state: "waiting",
|
|
states: [{ name: "waiting" }, { name: "done", terminal: true }],
|
|
transitions: [
|
|
{
|
|
id: "send",
|
|
from: "waiting",
|
|
to: "done",
|
|
trigger: { protocol: "websocket_connect", path: "/socket" },
|
|
actions: Array.from({ length: 5 }, () => ({
|
|
type: "websocket_send",
|
|
data: largeBody,
|
|
})),
|
|
max_uses: 1,
|
|
},
|
|
],
|
|
max_transitions: 1,
|
|
},
|
|
events: [],
|
|
});
|
|
|
|
expect(parsed.success).toBe(false);
|
|
if (!parsed.success)
|
|
expect(parsed.error.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ path: ["machine", "transitions"] }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("bounds action-field and sensitive-redaction work structurally", () => {
|
|
const input = loginRun();
|
|
const excessiveCaptures = replayMachineRunInputSchema.safeParse({
|
|
...input,
|
|
events: Array.from({ length: 1_025 }, () => input.events[0]),
|
|
});
|
|
expect(excessiveCaptures.success).toBe(false);
|
|
if (!excessiveCaptures.success)
|
|
expect(excessiveCaptures.error.issues).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ path: ["events"] })]),
|
|
);
|
|
|
|
const excessiveFields = replayMachineRunInputSchema.safeParse({
|
|
machine: {
|
|
initial_state: "waiting",
|
|
states: [{ name: "waiting" }, { name: "done", terminal: true }],
|
|
transitions: [
|
|
{
|
|
id: "respond",
|
|
from: "waiting",
|
|
to: "done",
|
|
trigger: { protocol: "http", method: "GET", path: "/done" },
|
|
actions: [
|
|
{
|
|
type: "http_response",
|
|
status: 200,
|
|
headers: Object.fromEntries(
|
|
Array.from({ length: 1_024 }, (_, index) => [
|
|
`x-field-${String(index)}`,
|
|
"",
|
|
]),
|
|
),
|
|
body: "",
|
|
},
|
|
],
|
|
max_uses: 1,
|
|
},
|
|
],
|
|
max_transitions: 1,
|
|
},
|
|
events: [],
|
|
});
|
|
expect(excessiveFields.success).toBe(false);
|
|
if (!excessiveFields.success)
|
|
expect(excessiveFields.error.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ path: ["machine", "transitions"] }),
|
|
]),
|
|
);
|
|
});
|
|
});
|