Files
tech-log-frontend/tests/unit/task3-selective-integration.test.ts
T
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and
neither could ever have content: the read path existed, the write path did not.
This adds the seven release operations to the contract contribution and the
gateway, and a Studio screen that can actually write one.

The editor is six markdown fields rather than one, because that is what the
contract models and what a release note is — why, what, what a reader notices,
what it leaves in the code, how it was verified, what is still open. Publishing
is separate from saving: a draft saves in any state, but the public query keys
on PUBLISHED alone, so publish is where completeness is demanded.

No new CSS. The screen reuses the document editor's field classes and the
working-copy list's row classes, so it inherits Studio's spacing and type
instead of introducing a second look.

The footer previously linked `/releases/0.1.0` — a version that did not exist,
so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now
points at the changelog index, which is the only place that knows what the
latest release is and which reads correctly when there are none.

Route inventory, navigation order, message catalog, manual accessibility
evidence, artifact baseline, and the pinned gate-shape digest all move with the
new route. The digest was recomputed by first reproducing the previous constant
from the previous gates.json, so the computation is known to be the one it was
pinned under.
2026-08-21 03:08:41 +09:00

258 lines
10 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 {
isReducedCiContractRun,
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");
});
// A removal fixture runs against a pruned contract on purpose, so the
// canonical counts do not describe it. Asserting them there failed the
// fixture for the reduction it exists to demonstrate.
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
// Template merge. The template added one gate and one command to the
// product's 26/81; the artifact set is the product's 126 plus the
// template's 2. Task 11 added TECH_LOG_STUDIO_ASSETS's manual
// accessibility evidence, bringing the total to 129.
// Final fix wave item 1 added `check-tech-log-contract` to FE-GATE-010.
// Alignment follow-up item 2 added `test-tech-log` and its junit report to
// FE-GATE-007, bringing the totals to 84/96/130. The dev release manifest
// drift fix added `check-dev-release-manifest` to FE-GATE-010: 85/97/130.
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(85);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
expect(canonical.artifacts).toHaveLength(132);
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.skipIf(isReducedCiContractRun())("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);
}
}