Files
rea/tests/boundary/process/processCaptureValidation.test.ts
T
2026-07-29 22:00:38 +00:00

329 lines
9.6 KiB
TypeScript

import { expect, it } from "vitest";
import { createProcessCaptureJournal } from "../../../src/application/ProcessCaptureJournal.js";
import {
compareProcessCaptures,
digestProcessCommitment,
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessCapture,
processCaptureSchema,
validateProcessCapture,
type ProcessCapture,
type ProcessCaptureEventJournalEntry,
} from "../../../src/domain/processCapture.js";
const emptyCapture = (): ProcessCapture => {
const normalization = {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
};
const scenario = { executable_sha256: "0".repeat(64) };
const comparisonContract = {};
const shimPlan: readonly unknown[] = [];
const replayPlan = {};
return {
schema_version: 4,
manifest: {
rea_version: "test",
provider_version: "3",
platform: process.platform,
architecture: process.arch,
pty_backend: "node-pty",
started_at: "2026-01-01T00:00:00.000Z",
completed_at: "2026-01-01T00:00:00.001Z",
scenario,
comparison_contract: comparisonContract,
shim_plan: shimPlan,
replay_plan: replayPlan,
full_scenario_sha256: digestProcessCommitment(scenario),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
executable_sha256: "0".repeat(64),
normalization_sha256: digestProcessCommitment(normalization),
shim_plan_sha256: digestProcessCommitment(shimPlan),
replay_plan_sha256: digestProcessCommitment(replayPlan),
},
normalization,
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" },
settlement: {
state: "quiesced",
elapsed_ms: 50,
cleanup_outcome: "not_required",
},
process_samples: [],
filesystem_checkpoints: [
{ name: "before", at_ms: 0, files: [], effects: [], truncated: false },
{
name: "after_settlement",
at_ms: 50,
files: [],
effects: [],
truncated: false,
},
],
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
event_journal: [],
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified",
temporary_root: "removed",
},
};
};
it("never considers truncated captures equivalent", () => {
const capture = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: true,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
expect(compareProcessCaptures(capture, capture).status).toBe("truncated");
});
it("rejects altered v4 commitments and accepts canonical key reordering", () => {
const capture = emptyCapture();
expect(parseProcessCapture(capture)).toEqual(capture);
expect(digestProcessCommitment({ second: 2, first: 1 })).toBe(
digestProcessCommitment({ first: 1, second: 2 }),
);
expect(() =>
parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
normalization_sha256: "f".repeat(64),
},
}),
).toThrow("normalization_sha256");
expect(() =>
parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
executable_sha256: "f".repeat(64),
},
}),
).toThrow("executable_sha256");
});
it("accepts old captures without a journal and validates complete journals", () => {
const journal = createProcessCaptureJournal();
const observed: ProcessCaptureEventJournalEntry[] = [];
const unsubscribe = journal.subscribe((entry) => observed.push(entry));
journal.recordEvent("lifecycle", 0);
const settlementEntry = journal.record("lifecycle", 1);
unsubscribe();
journal.recordEvent("frames", 0);
expect(journal.entries).toEqual([
{ capture_order: 0, collection: "lifecycle", index: 0 },
{ capture_order: 1, collection: "lifecycle", index: 1 },
{ capture_order: 2, collection: "frames", index: 0 },
]);
expect(observed).toEqual(journal.entries.slice(0, 2));
expect(settlementEntry).toEqual(journal.entries[1]);
const nestedJournal = createProcessCaptureJournal();
const notifications: string[] = [];
nestedJournal.subscribe((entry) => {
notifications.push(`a:${String(entry.capture_order)}`);
if (entry.capture_order === 0) nestedJournal.record("frames", 1);
});
nestedJournal.subscribe((entry) =>
notifications.push(`b:${String(entry.capture_order)}`),
);
nestedJournal.record("frames", 0);
expect(notifications).toEqual(["a:0", "b:0", "a:1", "b:1"]);
const capture = emptyCapture();
const { event_journal: _eventJournal, ...oldCapture } = capture;
expect(parseProcessCapture(oldCapture).event_journal).toEqual([]);
const eventJournal = [
{ capture_order: 0, collection: "filesystem_checkpoints", index: 0 },
{ capture_order: 1, collection: "lifecycle", index: 0 },
{ capture_order: 2, collection: "lifecycle", index: 1 },
{ capture_order: 3, collection: "filesystem_checkpoints", index: 1 },
] as const;
expect(
validateProcessCapture({ ...capture, event_journal: eventJournal }),
).toEqual([]);
for (const [candidate, message] of [
[
eventJournal.map((entry, index) =>
index === 1 ? { ...entry, capture_order: 2 } : entry,
),
"contiguous",
],
[
eventJournal.map((entry, index) =>
index === 3
? { ...entry, collection: "lifecycle" as const, index: 1 }
: entry,
),
"unique",
],
[eventJournal.slice(0, 3), "every captured observation"],
[
eventJournal.map((entry, index) =>
index === 3 ? { ...entry, index: 2 } : entry,
),
"outside",
],
] as const) {
expect(
validateProcessCapture({ ...capture, event_journal: candidate }),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(message),
}),
]),
);
}
});
it.each([
["machine limit", 1, 5, 10],
["transition use limit", 5, 1, 10],
["state visit limit", 5, 5, 1],
])(
"rejects replay journals that exceed the declared %s",
(_name, maxTransitions, maxUses, maxVisits) => {
const replayPlan = {
machine: {
initial_state: "active",
states: [
{ name: "active", max_visits: maxVisits },
{ name: "complete", terminal: true },
],
transitions: [
{
id: "again",
from: "active",
to: "active",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "again",
},
actions: [{ type: "websocket_send", data: "again" }],
max_uses: maxUses,
},
{
id: "finish",
from: "active",
to: "complete",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "finish",
},
actions: [{ type: "websocket_send", data: "done" }],
max_uses: 1,
},
],
max_transitions: maxTransitions,
},
};
const capture = emptyCapture();
const candidate: ProcessCapture = {
...capture,
manifest: {
...capture.manifest,
replay_plan: replayPlan,
replay_plan_sha256: digestProcessCommitment(replayPlan),
},
replay_transitions: [0, 1].map((sequence) => ({
sequence,
at_ms: sequence,
transition_id: "again",
state_before: "active",
state_after: "active",
sensitive_aliases: [],
})),
};
expect(validateProcessCapture(candidate)).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("limit"),
}),
]),
);
},
);
it("tells agents and users to recapture unsupported v3 evidence", () => {
const legacy = { ...emptyCapture(), schema_version: 3 };
expect(() => parseProcessCapture(legacy)).toThrow(
LEGACY_PROCESS_CAPTURE_MESSAGE,
);
const parsed = processCaptureSchema.safeParse(legacy);
expect(parsed.success).toBe(false);
if (parsed.success) throw new Error("expected legacy capture rejection");
expect(parsed.error.issues[0]?.message).toBe(LEGACY_PROCESS_CAPTURE_MESSAGE);
});
it("requires compatible contracts and enforces capture age through a clock seam", () => {
const capture = emptyCapture();
expect(() =>
compareProcessCaptures(capture, {
...capture,
manifest: {
...capture.manifest,
comparison_contract: { changed: true },
comparison_contract_sha256: digestProcessCommitment({
changed: true,
}),
},
}),
).toThrow("incompatible comparison contracts");
expect(() =>
compareProcessCaptures(capture, capture, {
maxCaptureAgeMs: 1,
now: () => Date.parse("2026-01-01T00:00:01.000Z"),
}),
).toThrow("max_capture_age_ms");
});