cast/test/secrets.test.ts
dan-claude-bot 7f0e886851 fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.

The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.

Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.

Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.

Refs #117
2026-07-21 12:58:14 +00:00

143 lines
5.9 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { closeSync, mkdirSync, openSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js";
import { tmp } from "./helpers/tmp.js";
// A key file and a store encrypted to it, for the decrypt tests.
function ageFixture(): { keyFile: string; enc: string } {
const dir = tmp("infra-age-");
const keyFile = join(dir, "key.txt");
execFileSync("age-keygen", ["-o", keyFile]);
const recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
const plain = join(dir, "s.env");
writeFileSync(plain, "MAILGUN_KEY=mk-123\nOPENROUTER_KEY=or-456\n");
const enc = join(dir, "s.env.age");
execFileSync("age", ["-r", recipient, "-o", enc, plain]);
return { keyFile, enc };
}
describe("decryptSecrets", () => {
it("round-trips an env file through age", () => {
const { keyFile, enc } = ageFixture();
expect(decryptSecrets(enc, keyFile)).toEqual({
MAILGUN_KEY: "mk-123",
OPENROUTER_KEY: "or-456",
});
});
it("accepts a key path only this process can resolve — what <(pm read …) injects", () => {
// Process substitution hands cast a path like /dev/fd/11 that is
// meaningful only inside the process holding the fd. A spawned age does
// not hold it, so passing the path through as `-i <path>` can never work;
// the identity must travel to age on stdin. Opening the key here and
// pointing at our own fd reproduces exactly that shape. /dev/fd works on
// both Linux (symlink to /proc/self/fd) and macOS, where /proc is absent.
const { keyFile, enc } = ageFixture();
const fd = openSync(keyFile, "r");
try {
expect(decryptSecrets(enc, `/dev/fd/${fd}`)).toEqual({
MAILGUN_KEY: "mk-123",
OPENROUTER_KEY: "or-456",
});
} finally {
closeSync(fd);
}
});
});
describe("secretsFileFor", () => {
it("resolves the age store under the state dir it is given, not the cwd", () => {
expect(secretsFileFor("/srv/state", "widget", "prod")).toBe(
"/srv/state/secrets/widget.prod.env.age",
);
});
});
// Pins the read-once shape of `<(pm read …)` that the fd-path test above
// cannot: a regular file behind an fd path re-opens at offset 0 on every
// read, but a pipe drains. `diff --all` / `apply --all` decrypt once per
// project, so the identity must be read once per process and reused.
describe("decryptSecrets identity caching", () => {
it("a read-once pipe key survives two decrypts — the --all loop shape", () => {
const { keyFile, enc } = ageFixture();
const dir = tmp("infra-fifo-");
const fifo = join(dir, "key.fifo");
execFileSync("mkfifo", [fifo]);
// One writer, one serving of the key: exactly what a process substitution
// delivers. It pairs with the first decrypt's open and exits.
const once = spawn("sh", ["-c", `cat "${keyFile}" > "${fifo}"`], {
stdio: "ignore",
});
const expected = { MAILGUN_KEY: "mk-123", OPENROUTER_KEY: "or-456" };
expect(decryptSecrets(enc, fifo)).toEqual(expected);
// The pipe is now drained. A second writer serves nothing, so if the
// per-process cache ever regresses, the re-read hands age an empty
// identity and fails loudly instead of blocking the suite on a
// writerless FIFO open. With the cache, nobody opens the FIFO again and
// the writer is still blocked in open() when we kill it.
const drained = spawn("sh", ["-c", `: > "${fifo}"`], { stdio: "ignore" });
try {
expect(decryptSecrets(enc, fifo)).toEqual(expected);
} finally {
once.kill("SIGKILL");
drained.kill("SIGKILL");
}
});
});
describe("keyFileFor", () => {
it("an env with no injected var and no standing key refuses, naming both ways in", () => {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
expect(() => keyFileFor("prod")).toThrow(
/no age key for prod.*CAST_AGE_KEY_FILE_PROD.*age-prod\.key/s,
);
});
it("the injected var wins, and is resolved per environment name", () => {
process.env.CAST_AGE_KEY_FILE_PROD = "/tmp/prod.key";
expect(keyFileFor("prod")).toBe("/tmp/prod.key");
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
});
// #102: `drill-b`.toUpperCase() is `DRILL-B`, and a var named
// CAST_AGE_KEY_FILE_DRILL-B cannot be set by any POSIX shell — the injected
// channel (and its process-substitution trick) was unreachable for every
// hyphenated environment name. Non-alphanumerics map to `_`.
it("a hyphenated env name maps to a settable var name", () => {
process.env.CAST_AGE_KEY_FILE_DRILL_B = "/tmp/drill-b.key";
try {
expect(keyFileFor("drill-b")).toBe("/tmp/drill-b.key");
} finally {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_DRILL_B");
}
});
it("the refusal advertises the mapped (settable) var, and the exact-name standing path", () => {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_DRILL_B");
// Isolate $HOME: a standing age-drill-b.key on the dev machine must not
// turn the refusal into a hit (os.homedir() reads $HOME on POSIX).
const home = process.env.HOME;
process.env.HOME = tmp("cast-home-");
try {
expect(() => keyFileFor("drill-b")).toThrow(
/no age key for drill-b.*CAST_AGE_KEY_FILE_DRILL_B.*age-drill-b\.key/s,
);
} finally {
process.env.HOME = home;
}
});
it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME;
const dir = tmp("cast-home-");
const cfg = join(dir, ".config", "cast");
mkdirSync(cfg, { recursive: true });
writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n");
process.env.HOME = dir; // os.homedir() reads $HOME on POSIX
try {
expect(keyFileFor("staging")).toBe(join(cfg, "age-staging.key"));
} finally {
process.env.HOME = home;
}
});
});