285 lines
9.8 KiB
TypeScript
285 lines
9.8 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
import {
|
|
captureProcessScenarioFile,
|
|
compareProcessEvidenceFiles,
|
|
projectProcessCliError,
|
|
} from "../../../src/application/process/ProcessCli.js";
|
|
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
|
|
import { createEvidence } from "../../../src/domain/evidence.js";
|
|
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
|
|
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
|
|
|
|
describe("process CLI error projection", () => {
|
|
it("preserves local diagnostics for unexpected process failures", () => {
|
|
const projected = projectProcessCliError(
|
|
new Error("SECRET internal failure /private/path"),
|
|
);
|
|
expect(projected).toEqual({
|
|
error: "Process command failed",
|
|
category: "execution_failure",
|
|
message:
|
|
"Process command could not complete: SECRET internal failure /private/path",
|
|
});
|
|
expect(JSON.stringify(projected)).not.toContain("stack");
|
|
});
|
|
});
|
|
|
|
describe("process CLI file cancellation", () => {
|
|
it("projects pre-aborted file reads as cancellation", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-cancel-");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
expect(
|
|
await captureProcessScenarioFile(
|
|
join(root, "missing-scenario.json"),
|
|
process.env,
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
details: { operation: "capture_process_scenario" },
|
|
});
|
|
expect(
|
|
await compareProcessEvidenceFiles(
|
|
join(root, "missing-left.json"),
|
|
join(root, "missing-right.json"),
|
|
join(root, "missing-trace.json"),
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
details: { operation: "compare_process_captures" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ workflow: "capture", input: "scenario" },
|
|
{ workflow: "compare", input: "left" },
|
|
{ workflow: "compare", input: "right" },
|
|
{ workflow: "compare", input: "trace specification" },
|
|
] as const)(
|
|
"stops streamed $workflow $input reads after cancellation",
|
|
async ({ workflow, input }) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-stream-");
|
|
const paths = {
|
|
scenario: join(root, "scenario.json"),
|
|
left: join(root, "left.json"),
|
|
right: join(root, "right.json"),
|
|
trace: join(root, "trace.json"),
|
|
};
|
|
const evidence = JSON.stringify(processCaptureEvidence());
|
|
const scenario = JSON.stringify({
|
|
executable: process.execPath,
|
|
arguments: [],
|
|
});
|
|
const traceSpecification = JSON.stringify(validTraceSpecification());
|
|
const contentsByInput = {
|
|
scenario: input === "scenario" ? stream(scenario) : scenario,
|
|
left: input === "left" ? stream(evidence) : evidence,
|
|
right: input === "right" ? stream(evidence) : evidence,
|
|
trace:
|
|
input === "trace specification"
|
|
? stream(traceSpecification)
|
|
: traceSpecification,
|
|
};
|
|
await Promise.all([
|
|
writeFile(paths.scenario, contentsByInput.scenario),
|
|
writeFile(paths.left, contentsByInput.left),
|
|
writeFile(paths.right, contentsByInput.right),
|
|
writeFile(paths.trace, contentsByInput.trace),
|
|
]);
|
|
const targetPath =
|
|
paths[input === "trace specification" ? "trace" : input];
|
|
const contents =
|
|
contentsByInput[input === "trace specification" ? "trace" : input];
|
|
const reader = await holdFirstFileRead(targetPath);
|
|
onTestFinished(reader.release);
|
|
const controller = new AbortController();
|
|
try {
|
|
const pending =
|
|
workflow === "capture"
|
|
? captureProcessScenarioFile(
|
|
paths.scenario,
|
|
process.env,
|
|
controller.signal,
|
|
)
|
|
: compareProcessEvidenceFiles(
|
|
paths.left,
|
|
paths.right,
|
|
paths.trace,
|
|
controller.signal,
|
|
);
|
|
await reader.firstRead;
|
|
controller.abort();
|
|
reader.release();
|
|
expect(await pending).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
});
|
|
expect(reader.readCount).toBe(1);
|
|
expect(reader.totalReadBytes).toBeLessThan(contents.length);
|
|
} finally {
|
|
controller.abort();
|
|
reader.release();
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("process CLI input diagnostics", () => {
|
|
it("projects missing, malformed JSON, and invalid scenario files", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-diagnostics-");
|
|
const malformed = join(root, "malformed.json");
|
|
const invalid = join(root, "invalid.json");
|
|
const invalidUtf8 = join(root, "invalid-utf8.json");
|
|
await writeFile(malformed, "not-json");
|
|
await writeFile(invalid, "{}");
|
|
await writeFile(
|
|
invalidUtf8,
|
|
Buffer.concat([
|
|
Buffer.from('{"executable":"'),
|
|
Buffer.from([0xff]),
|
|
Buffer.from('"}'),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
await captureProcessScenarioFile(join(root, "missing.json")),
|
|
).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file could not be read: ${join(root, "missing.json")} (ENOENT`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(malformed)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file is not valid UTF-8 JSON: ${malformed}`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(invalidUtf8)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file is not valid UTF-8 JSON: ${invalidUtf8}`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(invalid)).toEqual({
|
|
error: "Process command failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
message: "Analysis input is invalid. Check the arguments and try again.",
|
|
retryable: true,
|
|
remediation: { action: "Correct the listed arguments and retry." },
|
|
details: expect.objectContaining({
|
|
operation: "capture_process_scenario",
|
|
issues: expect.arrayContaining([
|
|
expect.objectContaining({ path: ["executable"] }),
|
|
]),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves the open-file owner when close fails (cancelled: %s)",
|
|
async (cancelDuringClose) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-cleanup-");
|
|
const path = join(root, "scenario.json");
|
|
await writeFile(path, JSON.stringify({ executable: process.execPath }));
|
|
const controller = new AbortController();
|
|
const cleanupCause = new Error("injected file close failure");
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async () => {
|
|
if (cancelDuringClose) controller.abort();
|
|
throw cleanupCause;
|
|
});
|
|
let failure: unknown;
|
|
try {
|
|
try {
|
|
await captureProcessScenarioFile(
|
|
path,
|
|
process.env,
|
|
cancelDuringClose ? controller.signal : undefined,
|
|
);
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected the retained file handle owner");
|
|
expect(failure.cleanupCause).toBe(cleanupCause);
|
|
expect(failure.owner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
close.mockRestore();
|
|
if (failure instanceof RegularFileCleanupFailure)
|
|
await failure.owner.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("names a leading byte-order mark instead of quoting it as a token", async () => {
|
|
const path = join(
|
|
await createTestTempDirectory("rea-process-cli-diagnostics-"),
|
|
"scenario.json",
|
|
);
|
|
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
|
|
expect(await captureProcessScenarioFile(path)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
|
|
});
|
|
});
|
|
});
|
|
|
|
const processCaptureEvidence = () =>
|
|
createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "rea.process-capture",
|
|
operation: "capture_process_scenario",
|
|
parameters: {},
|
|
result: EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
|
confidence: "observed",
|
|
authority: "controlled-replay",
|
|
environment: {
|
|
id: "fixture",
|
|
platform: "fixture",
|
|
architecture: "fixture",
|
|
isolation: "process",
|
|
},
|
|
});
|
|
|
|
const stream = (json: string) => " ".repeat(8 * 1024 * 1024) + json;
|
|
|
|
const validTraceSpecification = () => ({
|
|
events: [
|
|
{
|
|
id: "started",
|
|
source: "lifecycle",
|
|
exact: { state: "started" },
|
|
},
|
|
{
|
|
id: "finished",
|
|
source: "lifecycle",
|
|
exact: { state: "finished" },
|
|
},
|
|
],
|
|
language: {
|
|
kind: "partial_order",
|
|
happens_before: [{ before: "started", after: "finished" }],
|
|
},
|
|
});
|