Files
tech-log-frontend/tests/unit/task3-selective-integration.test.ts
T
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +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(134);
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);
}
}