Files
tech-log-frontend/tests/unit/repository-file-inventory.test.ts
T

279 lines
9.2 KiB
TypeScript

import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
type GitFileListResult,
} from "../../scripts/lib/repository-file-inventory.ts";
function gitResult(
stdout: Buffer | string,
overrides: Partial<GitFileListResult> = {},
): GitFileListResult {
return {
status: 0,
signal: null,
stdout: typeof stdout === "string" ? Buffer.from(stdout) : stdout,
stderr: Buffer.alloc(0),
...overrides,
};
}
async function repositoryFixture() {
const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-"));
await mkdir(path.join(root, "src"));
await mkdir(path.join(root, "docs"));
await writeFile(path.join(root, "src", "tracked.ts"), "tracked\n");
await writeFile(path.join(root, "src", "untracked.ts"), "untracked\n");
await writeFile(path.join(root, "README.md"), "readme\n");
await writeFile(path.join(root, "docs", "outside-policy.md"), "docs\n");
return root;
}
describe("repository file inventory", () => {
it("rejects malformed root policies instead of filtering invalid entries", () => {
expect(() =>
parseRepositoryFileInventoryPolicy({
trackedRoots: ["src", 42],
generatedRoots: [],
}),
).toThrow(/trackedRoots/u);
expect(() =>
parseRepositoryFileInventoryPolicy({
trackedRoots: [],
generatedRoots: [],
}),
).toThrow(/trackedRoots/u);
});
it.each([
["spawn failure", { error: new Error("spawn ENOENT") }],
["non-zero exit", { status: 2, stderr: Buffer.from("fatal") }],
["signal", { status: null, signal: "SIGTERM" }],
["stderr output", { stderr: Buffer.from("warning") }],
] as const)("fails closed on git %s", async (_name, failure) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0", failure),
}),
).rejects.toThrow(/git ls-files/u);
});
it.each([
["malformed UTF-8", Buffer.from([0xc3, 0x28, 0])],
["embedded empty NUL row", Buffer.from("src/tracked.ts\0\0")],
["missing terminal NUL", Buffer.from("src/tracked.ts")],
])("rejects %s output", async (_name, stdout) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(stdout),
}),
).rejects.toThrow(/git ls-files/u);
});
it("uses only tracked files and sorts the normalized inventory", async () => {
const repositoryRoot = await repositoryFixture();
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0"),
});
expect(inventory.trackedFiles).toEqual(["src/tracked.ts"]);
expect(inventory.files).not.toContain("src/untracked.ts");
});
it("includes every Git-tracked file outside mandatory policy roots", async () => {
const repositoryRoot = await repositoryFixture();
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () =>
gitResult("src/tracked.ts\0README.md\0docs/outside-policy.md\0"),
});
expect(inventory.trackedFiles).toEqual([
"README.md",
"docs/outside-policy.md",
"src/tracked.ts",
]);
expect(inventory.files).toEqual(inventory.trackedFiles);
});
it("rejects duplicate tracked paths instead of silently deduplicating", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0src/tracked.ts\0"),
}),
).rejects.toThrow(/duplicate/u);
});
it("fails when a required root is missing", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["missing"],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/required repository root.*missing/u);
});
it("fails when an existing required root has no tracked match", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/tracked file.*src/u);
});
it("ignores only exact ENOENT for configured optional generated roots", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["optional-output"],
optionalRoots: ["optional-output"],
runGit: () => gitResult("src/tracked.ts\0"),
}),
).resolves.toMatchObject({ generatedFiles: [] });
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["optional-output"],
optionalRoots: ["optional-output"],
runGit: () => gitResult("src/tracked.ts\0"),
lstatPath: async (target) => {
if (target.endsWith("optional-output")) {
throw Object.assign(new Error("denied"), { code: "EACCES" });
}
const { lstat } = await import("node:fs/promises");
return lstat(target);
},
}),
).rejects.toThrow(/optional-output/u);
});
it.each(["/absolute", "../escape", "src\\windows.ts"])(
"rejects unsafe configured path %s",
async (unsafePath) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: [unsafePath],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/repository-relative POSIX path/u);
},
);
it("rejects tracked traversal and non-regular files", async () => {
const repositoryRoot = await repositoryFixture();
for (const trackedPath of ["../escape", "src"]) {
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(`${trackedPath}\0`),
}),
).rejects.toThrow(/git ls-files|regular file/u);
}
});
it("rejects a tracked symlink whose real path escapes the repository", async () => {
const repositoryRoot = await repositoryFixture();
const outside = await mkdtemp(path.join(tmpdir(), "inventory-outside-"));
await writeFile(path.join(outside, "secret.ts"), "secret\n");
await symlink(
path.join(outside, "secret.ts"),
path.join(repositoryRoot, "src", "link.ts"),
);
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/link.ts\0"),
}),
).rejects.toThrow(/symlink|regular file/u);
});
it("adds only explicitly configured generated regular files", async () => {
const repositoryRoot = await repositoryFixture();
await mkdir(path.join(repositoryRoot, "dist"));
await writeFile(path.join(repositoryRoot, "dist", "asset.js"), "asset\n");
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["dist"],
runGit: () => gitResult("src/tracked.ts\0"),
});
expect(inventory.generatedFiles).toEqual(["dist/asset.js"]);
expect(inventory.files).toEqual(["dist/asset.js", "src/tracked.ts"]);
});
it("rejects an exact tracked and generated path collision", async () => {
const repositoryRoot = await repositoryFixture();
await writeFile(path.join(repositoryRoot, "package.json"), "{}\n");
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["package.json"],
generatedRoots: ["package.json"],
runGit: () => gitResult("package.json\0"),
}),
).rejects.toThrow(/tracked.*generated.*package\.json/u);
});
it("accepts a legal child whose name begins with two dots", async () => {
const repositoryRoot = await repositoryFixture();
await mkdir(path.join(repositoryRoot, "..assets"));
await writeFile(path.join(repositoryRoot, "..assets", "legal.ts"), "legal\n");
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["..assets"],
runGit: () => gitResult("..assets/legal.ts\0"),
}),
).resolves.toMatchObject({ trackedFiles: ["..assets/legal.ts"] });
});
it("fails closed when an inventoried file cannot be read", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0"),
assertReadable: async () => {
throw Object.assign(new Error("denied"), { code: "EACCES" });
},
}),
).rejects.toThrow(/src\/tracked\.ts/u);
});
});