test(environment): verify export failures through the real CLI

Remove shared-module mocks for input reads, heap statistics, and tarball streaming. Exercise actual file changes and real V8 heap admission in fresh CLI processes; keep only selected-descriptor syscall fault injection for the pathless EIO case.

Reproduced the four hosted source failures with a preloaded module graph and found the same flaw in the adjacent tarball test. The preloaded control now passes 17 cases; 32 focused tests, all 1,739 service tests (2 skips), and check:ci pass. Snapshot, cleanup, identity, and error projection coverage is retained.
This commit is contained in:
morluto committed 2026-10-21 18:26:58 +08:00
1 parent 5eebe2fdf8
commit c324d31b45
4 files changed
+235 -157

No files matched your search

@@ -9,7 +9,6 @@ import {
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { expect, it, vi } from "vitest";
@@ -19,7 +18,6 @@ import {
writeEnvironmentServeFiles,
} from "../../../tests/fixtures/environmentServe.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import * as fileChunks from "../../filesystem/readFileHandleChunks.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
AnalysisAccessDeniedError,
@@ -30,61 +28,6 @@ import {
readEnvironmentExportInputs,
} from "./environmentExport.js";
vi.mock("../../filesystem/readFileHandleChunks.js", { spy: true });
it("attributes a pathless tarball read failure during the copy to the tarball", async () => {
const root = await createTestTempDirectory("rea-tarball-read-failure-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const tarball = join(root, "package.tgz");
await writeFile(tarball, "admitted package");
const directory = join(root, "bundle");
const actual = await vi.importActual<typeof fileChunks>(
"../../filesystem/readFileHandleChunks.js",
);
// Only the tarball descriptor is read twice: once to hash, once to copy.
const reads = new Map<unknown, number>();
const reader = vi
.mocked(fileChunks.readFileHandleChunks)
.mockImplementation((handle, options) => {
const count = (reads.get(handle) ?? 0) + 1;
reads.set(handle, count);
if (count < 2) return actual.readFileHandleChunks(handle, options);
const failure = Object.assign(new Error("EIO: i/o error, read"), {
code: "EIO",
syscall: "read",
});
return Object.assign(
new Readable({
read() {
this.destroy(failure);
},
}),
{ readCoverage: () => ({ status: "unknown" as const }) },
) as unknown as ReturnType<typeof fileChunks.readFileHandleChunks>;
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
packageTarball: tarball,
});
if (result.ok || !(result.error instanceof AnalysisOutputError))
throw new Error("expected an output failure");
expect(result.error.reason).toContain(`reading ${tarball}`);
expect(result.error.remediationAction).toContain(tarball);
expect(result.error.remediationAction).not.toContain("disk space");
expect(result.error.diagnostics).toEqual({ output_directory: directory });
} finally {
reader.mockRestore();
}
});
it("retains the admitted tarball descriptor and refuses a pathname replacement during output", async () => {
const root = await createTestTempDirectory("rea-tarball-instance-");
const files = await writeEnvironmentServeFiles(root);
@@ -1,8 +1,7 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, sep } from "node:path";
import * as v8 from "node:v8";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
environmentServeFixture,
@@ -12,17 +11,11 @@ import {
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import { RegularFileChangedError } from "../../filesystem/RegularFile.js";
import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js";
import * as regularFileRead from "../RegularFileRead.js";
import {
exportEnvironmentBundle,
readEnvironmentExportInputs,
} from "./environmentExport.js";
vi.mock("node:v8", { spy: true });
vi.mock("../RegularFileRead.js", { spy: true });
const listFiles = async (root: string): Promise<string[]> =>
(await readdir(root, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
@@ -52,41 +45,6 @@ const prepare = async (prefix: string) => {
};
describe("readEnvironmentExportInputs", () => {
it.each(["manifest", "evidence"] as const)(
"returns a tagged input error when the %s snapshot changes",
async (role) => {
const root = await createTestTempDirectory(
"rea-environment-input-changed-",
);
const files = await writeEnvironmentServeFiles(root);
const changedPath =
role === "manifest" ? files.manifestPath : files.capturePath;
const actual = await vi.importActual<typeof regularFileRead>(
"../RegularFileRead.js",
);
const reader = vi.mocked(regularFileRead.readRegularFile);
reader.mockImplementation(async (path, options) => {
if (path === changedPath) throw new RegularFileChangedError(path);
return actual.readRegularFile(path, options);
});
try {
const result = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (result.ok) throw new Error("Expected changed input refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: [role], reason: "invalid_value" }],
},
});
} finally {
reader.mockImplementation(actual.readRegularFile);
}
},
);
it("reads each file once as the bytes on disk and the value they parse to", async () => {
const root = await createTestTempDirectory("rea-environment-inputs-");
const fixture = environmentServeFixture();
@@ -107,32 +65,6 @@ describe("readEnvironmentExportInputs", () => {
expect(inputs.value.evidence.value).toEqual(fixture.capture);
});
it("refuses an input whose whole read and parse would not fit live heap headroom, before reading it", async () => {
const root = await createTestTempDirectory("rea-environment-export-heap-");
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
...v8.getHeapStatistics(),
total_available_size: 1024,
});
const refused = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
expect(refused.ok).toBe(false);
if (refused.ok) return;
expect(refused.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
resource: "memory",
reportedLimits: {
input_path: files.manifestPath,
available_heap_bytes: 1024,
},
});
});
it("refuses inline JSON, a directory and a file that is not JSON by role", async () => {
const root = await createTestTempDirectory("rea-environment-inputs-bad-");
const files = await writeEnvironmentServeFiles(root);
@@ -314,34 +246,3 @@ describe("exportEnvironmentBundle file modes", () => {
},
);
});
describe("readEnvironmentExportInputs heap admission", () => {
it("reserves headroom for snapshot sealing after the native parse", async () => {
const root = await createTestTempDirectory(
"rea-environment-export-copies-",
);
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
const { size } = await stat(files.manifestPath);
// Room for parsing, but not for the owned snapshot traversal that follows it.
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
...v8.getHeapStatistics(),
total_available_size: nativeJsonParseRequiredBytes(size) * 1.5,
});
const refused = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
expect(refused.ok).toBe(false);
if (refused.ok) return;
expect(refused.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
reportedLimits: {
input_path: files.manifestPath,
required_heap_bytes: nativeJsonParseRequiredBytes(size, true),
},
});
});
});
@@ -0,0 +1,193 @@
import {
appendFile,
readFile,
stat,
truncate,
writeFile,
} from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { expect } from "vitest";
import { z } from "zod";
import { nativeJsonParseRequiredBytes } from "../../../src/application/JsonInputFile.js";
import { writeEnvironmentServeFiles } from "../../fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const probe = pathToFileURL(
resolve("tests/fixtures/environmentExportInputProbe.mjs"),
).href;
for (const role of ["manifest", "evidence"] as const) {
cliTest(
`refuses a real ${role} file changed during its read`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-input-race-cli-");
const files = await writeEnvironmentServeFiles(root);
const input =
role === "manifest" ? files.manifestPath : files.capturePath;
const before = await readFile(input);
const marker = join(root, "read-marker");
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
environment: {
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
REA_TEST_EXPORT_INPUT: input,
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "append",
},
timeoutMs: 30_000,
});
expect(await readFile(marker, "utf8")).toBe("read");
expect(await readFile(input)).toEqual(
Buffer.concat([before, Buffer.from(" ")]),
);
expect(result.exitCode, result.stderr).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: [role], reason: "invalid_value" }],
},
});
await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
},
);
}
const heapRefusalSchema = z.object({
code: z.literal("resource_constraint"),
details: z.object({
operation: z.literal("environment-export"),
resource: z.literal("memory"),
reported_limits: z.object({
input_path: z.string(),
input_file_bytes: z.number(),
required_heap_bytes: z.number(),
available_heap_bytes: z.number(),
}),
}),
});
cliTest(
"admits neither the whole read nor an unbudgeted snapshot under a real heap limit",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-input-heap-cli-");
const files = await writeEnvironmentServeFiles(root);
const originalBytes = (await stat(files.manifestPath)).size;
const marker = join(root, "read-marker");
const directory = join(root, "bundle");
// Whitespace preserves a valid manifest while making the actual file large.
await appendFile(files.manifestPath, Buffer.alloc(8 * 1024 * 1024, " "));
let available: number | undefined;
for (const phase of ["whole-read", "snapshot"] as const) {
if (available !== undefined) {
// Fit the native parse in two thirds of the previous child's real
// headroom, while the additional snapshot reservation cannot fit.
const overhead = nativeJsonParseRequiredBytes(0);
const perByte = nativeJsonParseRequiredBytes(1) - overhead;
await truncate(
files.manifestPath,
Math.max(
originalBytes,
Math.floor(((available * 2) / 3 - overhead) / perByte),
),
);
}
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
environment: {
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
REA_TEST_EXPORT_INPUT: files.manifestPath,
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "record",
},
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(1);
const limits = heapRefusalSchema.parse(result.json).details
.reported_limits;
expect(limits.input_path).toBe(files.manifestPath);
expect(limits.input_file_bytes).toBe(
(await stat(files.manifestPath)).size,
);
expect(limits.required_heap_bytes).toBe(
nativeJsonParseRequiredBytes(limits.input_file_bytes, true),
);
expect(limits.required_heap_bytes).toBeGreaterThan(
limits.available_heap_bytes,
);
if (phase === "snapshot")
expect(
nativeJsonParseRequiredBytes(limits.input_file_bytes),
).toBeLessThan(limits.available_heap_bytes);
else
expect(
nativeJsonParseRequiredBytes(limits.input_file_bytes),
).toBeGreaterThan(limits.available_heap_bytes);
available = limits.available_heap_bytes;
await expect(stat(marker)).rejects.toMatchObject({ code: "ENOENT" });
await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
}
},
);
cliTest(
"attributes a pathless descriptor failure during the real tarball copy",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-copy-read-cli-");
const files = await writeEnvironmentServeFiles(root);
const tarball = join(root, "package.tgz");
await writeFile(tarball, "admitted package");
const marker = join(root, "read-marker");
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--package-tarball",
tarball,
"--json",
],
environment: {
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
REA_TEST_EXPORT_INPUT: tarball,
REA_TEST_EXPORT_READ_MARKER: marker,
REA_TEST_EXPORT_READ_ACTION: "fail-copy",
},
timeoutMs: 30_000,
});
expect(await readFile(marker, "utf8")).toBe("copy");
expect(await readFile(tarball, "utf8")).toBe("admitted package");
expect(result.exitCode, result.stderr).toBe(1);
expect(result.json).toMatchObject({
code: "unreadable_output",
details: {
reason: expect.stringContaining(`reading ${tarball}`),
diagnostics: { output_directory: directory },
},
remediation: { action: expect.stringContaining(tarball) },
});
expect(JSON.stringify(result.json)).not.toContain("disk space");
},
);
+41
View File
@@ -0,0 +1,41 @@
import { promises as fs } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const path = process.env.REA_TEST_EXPORT_INPUT;
const marker = process.env.REA_TEST_EXPORT_READ_MARKER;
const action = process.env.REA_TEST_EXPORT_READ_ACTION;
if (!path || !marker || !["append", "record", "fail-copy"].includes(action))
throw new Error("Expected an explicit export input, read marker and action");
// Instrument only the selected real descriptor in this disposable CLI process.
// Reads and metadata checks remain the production filesystem operations.
const open = fs.open;
fs.open = async (...args) => {
const handle = await open(...args);
if (args[0] !== path) return handle;
const read = handle.read.bind(handle);
let recorded = false;
let hashed = false;
handle.read = async (...readArgs) => {
if (action === "fail-copy" && hashed) {
await fs.writeFile(marker, "copy");
throw Object.assign(new Error("controlled pathless descriptor failure"), {
code: "EIO",
syscall: "read",
});
}
const result = await read(...readArgs);
if (action === "fail-copy") {
hashed = result.bytesRead === 0;
return result;
}
if (!recorded) {
recorded = true;
await fs.writeFile(marker, "read");
if (action === "append") await fs.appendFile(path, " ");
}
return result;
};
return handle;
};
syncBuiltinESMExports();