Files
clean-architecture-frontend…/tests/unit/task3-selective-integration.test.ts
T
DongHyeonkaandClaude Opus 5 3ea3397691 fix: make the architecture and documentation rules say what is actually true
Three boundaries the layer contract declares had no executable rule behind
them, so the code drifted across all three while every gate stayed green.

`src/contracts` reached back up into `src/application` for the shared `Result`
carrier and the compatibility predicate. Neither package owned the shared
vocabulary and the dependency pointed both ways. Both now live in contracts —
the lower package — and application re-exports them, so no caller moves.

A concrete adapter was not supposed to depend on another concrete adapter, but
only adapter-to-presentation was enforced, and `diagnostics` imported a guard
out of `telemetry`. The guard belongs to neither, so it moved to the adapter
kernel. Stating the rule needed the checker to resolve `$1` in a `to` pattern
against the importing module's own directory; the alternative is one rule per
adapter group, which silently stops covering a group the moment one is added.

Product assembly leaks out of bootstrap: generic presentation reads the
installed-feature registries. That is a real refactor, so the rule freezes the
exact set of modules doing it today rather than pretending it is fixed — a new
edge fails. The two remaining open edges are named in the config, not silent.

Each rule was verified by introducing the violation it forbids and confirming
the gate rejects it.

The documentation drifted the same way. README and the manual accessibility
checklist both said six routes while ten were registered, which left the
platform overview and three reference-resource screens outside the declared
manual review scope without anyone deciding they should be. The scope is now
derived from the route registry by `verify:documentation`, so the sentence
cannot outlive the registry again. The review ledger also named a canonical
path that does not exist in this tree; it is upstream provenance, and it now
says so instead of looking like a broken repository reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:23:10 +09:00

246 lines
9.4 KiB
TypeScript

import { spawn } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
loadCiGateContract,
parseCiGateContract,
} from "../../scripts/contracts/ci-gates.ts";
import { generateCiWorkflow } from "../../scripts/generate-ci-workflow.ts";
import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("selective Task 3 contract closure", () => {
it("builds a private offline aggregate-cgroup provider launch without argv secrets", async () => {
const {
encodeProviderBwrapInput,
encodeProviderScopeFrame,
formatProviderCgroupUnitName,
systemctlKillProviderArguments,
systemdRunProviderArguments,
} = await import("../../scripts/lib/provider-cgroup.ts");
const unit = formatProviderCgroupUnitName(
"vulnerability",
42,
"0123456789abcdef01234567",
);
const command = "node provider.mjs --token command-secret";
const credential = "credential-secret";
const launch = systemdRunProviderArguments(
unit,
1_800_000,
1_200,
"/trusted/node",
"/workspace/scripts/lib/provider-scope-wrapper.ts",
"/exact/report.json",
12,
34,
);
expect(launch).toEqual(expect.arrayContaining([
"--scope",
"--property=MemoryMax=1073741824",
"--property=MemorySwapMax=0",
"--property=TasksMax=64",
"--property=CPUQuota=100%",
"--property=KillMode=control-group",
"/trusted/node",
"/workspace/scripts/lib/provider-scope-wrapper.ts",
]));
expect(launch.join("\0")).not.toContain(command);
expect(launch.join("\0")).not.toContain(credential);
expect(
encodeProviderBwrapInput(
["--unshare-net", "--bind", "/exact/report.json", "/exact/report.json"],
{ PROVIDER_COMMAND: command, PROVIDER_CREDENTIAL: credential },
),
).toEqual(expect.any(Buffer));
expect(systemctlKillProviderArguments(unit)).toEqual([
"--user",
"kill",
"--kill-whom=all",
"--signal=SIGKILL",
unit,
]);
// `bwrap --args FD` stops parsing at the first non-option and never hands
// the remainder back, so a command placed in the args file is dropped and
// bubblewrap exits with its usage text. Refusing `--` in the option stream
// is what keeps that silent no-sandbox launch from returning.
expect(() =>
encodeProviderBwrapInput(
["--unshare-net", "--", "/usr/bin/prlimit"],
{ PROVIDER_COMMAND: command },
),
).toThrow(/terminate the option stream/u);
const frame = encodeProviderScopeFrame({
bwrapInput: Buffer.from("private-bwrap-vector\0"),
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
});
expect(frame.readUInt32BE(0)).toBe(frame.byteLength - 4);
expect(frame.subarray(4).toString("utf8")).toContain(
Buffer.from("private-bwrap-vector\0").toString("base64"),
);
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
// The command vector rides on real argv, so it must never be able to carry
// the secret that the args file exists to hide.
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: [],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: ["prlimit"],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
});
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
const { cleanupOwnedProviderReport } = await import(
"../../scripts/lib/provider-raw-cleanup.ts"
);
const root = await mkdtemp(path.join(tmpdir(), "provider-raw-cleanup-"));
roots.push(root);
const reportPath = path.join(root, "raw.json");
const originalPath = path.join(root, "original.json");
await writeFile(reportPath, "owned\n");
const identity = await lstat(reportPath);
await rename(reportPath, originalPath);
await writeFile(reportPath, "unrelated\n");
await expect(cleanupOwnedProviderReport({
reportPath,
reportDev: identity.dev,
reportIno: identity.ino,
})).resolves.toBe(false);
await expect(readFile(reportPath, "utf8")).resolves.toBe("unrelated\n");
await rm(reportPath);
await rename(originalPath, reportPath);
await expect(cleanupOwnedProviderReport({
reportPath,
reportDev: identity.dev,
reportIno: identity.ino,
})).resolves.toBe(true);
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(root)).resolves.toEqual([]);
});
it("uses early liveness EOF to clean the pinned raw file without waiting for a command frame", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-scope-eof-"));
roots.push(root);
const reportPath = path.join(root, "raw.json");
await writeFile(reportPath, "partial\n");
const identity = await lstat(reportPath);
const child = spawn(process.execPath, [
path.resolve("scripts/lib/provider-scope-wrapper.ts"),
"1",
reportPath,
String(identity.dev),
String(identity.ino),
], { stdio: ["pipe", "pipe", "pipe"] });
const completion = waitForChildResult(child);
await new Promise<void>((resolve) => setTimeout(resolve, 75));
expect(child.exitCode).toBeNull();
await expect(readFile(reportPath, "utf8")).resolves.toBe("partial\n");
child.stdin.end();
const result = await within(completion, 1_000, "provider scope EOF close");
expect(result).toEqual({ code: 125, signal: null });
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(root)).resolves.toEqual([]);
});
it("tracks npm run-script dependencies instead of bypassing the graph", () => {
expect(validatePackageScriptGraph({ root: "npm run-script missing" }, "root"))
.toContain("package script missing: root -> missing");
});
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(82);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
expect(canonical.artifacts).toHaveLength(107);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
const orphan = JSON.parse(JSON.stringify(canonical)) as Record<string, any>;
orphan.retention.classes.push({ id: "unused", policy: "never referenced" });
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
});
it("rejects the retired validate-candidate-archive grammar", async () => {
const canonical = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
canonical.jobs.find((job: Record<string, any>) => job.id === "vulnerability_provider")
.steps.splice(4, 0, {
kind: "validate-candidate-archive",
archivePath: ".release/candidate/release-candidate.tar.gz",
});
expect(() => parseCiGateContract(canonical)).toThrow(
/invalid discriminator|forbidden|canonical job step sequence/iu,
);
});
it("publishes a generated workflow as exactly 0644 under a restrictive umask", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-mode-"));
roots.push(root);
await mkdir(path.join(root, ".gitea/workflows"), { recursive: true });
const previous = process.umask(0o777);
try {
const contract = await loadCiGateContract(process.cwd());
await generateCiWorkflow({ root, contract, check: false });
} finally {
process.umask(previous);
}
const target = path.join(root, ".gitea/workflows/quality-gates.yml");
const metadata = await lstat(target);
expect(metadata.mode & 0o777).toBe(0o644);
expect((await readFile(target, "utf8")).startsWith("# GENERATED FILE")).toBe(true);
});
});
async function waitForChildResult(
child: ReturnType<typeof spawn>,
): Promise<Readonly<{ code: number | null; signal: NodeJS.Signals | null }>> {
return await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
});
}
async function within<T>(operation: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}