cast/test/resolve.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

793 lines
28 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { computeDiff } from "../src/diff.js";
import { DERIVED_UNRESOLVED } from "../src/envtemplate.js";
import {
cloneFailureMessage,
desiredFromManifest,
fillDesiredDerived,
requiredSecrets,
resolveCheckout,
resolveGitAuth,
} from "../src/resolve.js";
import { tmp } from "./helpers/tmp.js";
describe("resolveCheckout", () => {
it("hard-refuses --path with prod", () => {
expect(() =>
resolveCheckout("acme/widget", { env: "prod", path: "/tmp/x" }),
).toThrow(/--path.*prod/);
});
it("returns --path for non-prod", () => {
expect(
resolveCheckout("acme/widget", {
env: "staging",
path: "/tmp/x",
}),
).toBe("/tmp/x");
});
// #117: the ephemeral checkout used to survive the process that made it, so
// every `cast apply`/`diff`/`capture` without --path left a full clone behind.
// This has to run in a real child process — the reaper is an exit hook, and
// the thing under test is precisely what happens when the process ends.
//
// A stub `git` on PATH makes it hermetic and fast: the clone fails, which is
// the *harder* case, since the directory is created before the clone runs and
// the failure path rethrows. If the dir is gone after a failed clone, the
// registration happens early enough to cover the successful one too.
it("removes the ephemeral checkout when the process exits", () => {
const bin = tmp("cast-fakebin-");
writeFileSync(join(bin, "git"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
// Other suites (and other machines) have their own checkouts lying around;
// only the one this child allocates is ours to assert on.
const preexisting = new Set(
readdirSync(tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
);
const script = `
const { resolveCheckout } = await import(${JSON.stringify(
pathToFileURL(join(process.cwd(), "dist/resolve.js")).href,
)});
try { resolveCheckout("acme/widget", { env: "dev" }); } catch {}
// Report what was allocated, then let the process exit normally.
const fs = await import("node:fs");
const os = await import("node:os");
console.log(JSON.stringify(
fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
));
`;
const out = execFileSync(
process.execPath,
["--input-type=module", "-e", script],
{
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
encoding: "utf8",
},
);
const allocated: string[] = JSON.parse(
out.trim().split("\n").pop() ?? "[]",
);
const mine = allocated.filter((d) => !preexisting.has(d));
// It must have allocated exactly one — otherwise this test proves nothing.
expect(mine).toHaveLength(1);
// ...and that one must be gone now that the child has exited.
expect(existsSync(join(tmpdir(), mine[0]))).toBe(false);
});
});
describe("resolveGitAuth", () => {
const noGh = () => false;
const yesGh = () => true;
it("prefers gh, borrowed as a per-invocation credential helper", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "t" }, yesGh);
expect(auth.source).toBe("gh");
expect(auth.configArgs.join(" ")).toContain("!gh auth git-credential");
// No token is materialized when gh is driving.
expect(auth.env).toEqual({});
});
it("falls back to GITHUB_TOKEN when gh is absent", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh);
expect(auth.source).toBe("token");
expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" });
});
it("accepts GH_TOKEN as well as GITHUB_TOKEN", () => {
const auth = resolveGitAuth({ GH_TOKEN: "ghp_secret" }, noGh);
expect(auth.source).toBe("token");
expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" });
});
// The acceptance criterion from #13: "the token never appears in process
// arguments or on disk". The helper string git receives must carry the
// NAME of the variable, never its value — sh expands it inside the helper.
it("never puts the token value in the git argv", () => {
const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh);
const argv = auth.configArgs.join(" ");
expect(argv).not.toContain("ghp_secret");
expect(argv).toContain("$CAST_GIT_TOKEN");
});
// A helper configured globally would otherwise be consulted first and
// silently decide the outcome, defeating the order cast just established.
it("resets the inherited helper list before installing its own", () => {
for (const auth of [
resolveGitAuth({}, yesGh),
resolveGitAuth({ GITHUB_TOKEN: "t" }, noGh),
]) {
expect(auth.configArgs.slice(0, 2)).toEqual(["-c", "credential.helper="]);
}
});
it("falls through to the ambient helper when there is nothing else", () => {
expect(resolveGitAuth({}, noGh)).toEqual({
source: "ambient",
configArgs: [],
env: {},
});
});
});
describe("cloneFailureMessage", () => {
const ambient = { source: "ambient" as const, configArgs: [], env: {} };
const gh = { source: "gh" as const, configArgs: [], env: {} };
// The original bug: git's own error talked about THE REPOSITORY when the
// real fault was cast having no credentials at all.
it("blames the missing credentials, not the repo, when there were none", () => {
const msg = cloneFailureMessage("heavy-duty/incubator", ambient, "");
expect(msg).toMatch(/no GitHub credentials/);
expect(msg).toMatch(/gh auth login/);
expect(msg).toMatch(/GITHUB_TOKEN/);
expect(msg).not.toMatch(/does not exist/);
});
// ...and the converse: once cast DID authenticate, the repo really is a
// candidate explanation again, and 404-means-403 has to be spelled out.
it("names both roads when a credential was used and GitHub still refused", () => {
const msg = cloneFailureMessage("heavy-duty/incubator", gh, "");
expect(msg).toMatch(/gh/);
expect(msg).toMatch(/does not exist/);
expect(msg).toMatch(/private/);
expect(msg).not.toMatch(/no GitHub credentials/);
});
it("passes git's own stderr through rather than swallowing it", () => {
const msg = cloneFailureMessage(
"heavy-duty/incubator",
ambient,
"fatal: could not read Username for 'https://github.com'",
);
expect(msg).toMatch(/could not read Username/);
});
});
describe("desiredFromManifest", () => {
it("maps manifest + templates to Desired[] with resolved env", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core-api:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
port: 3000
healthcheck: /health
domains: ["http://api.staging.example.com"]
env_template: core-api.staging.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", "core-api.staging.env.template"),
"PORT=3000\nMG=${MG}\n",
);
const { desired, resolvedEnvs } = desiredFromManifest(dir, "staging", {
MG: "secret-v",
});
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core-api",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/apps/core",
port: 3000,
healthcheck: "/health",
domains: ["http://api.staging.example.com"],
},
});
expect(resolvedEnvs["core-api"].vars.MG).toEqual({
value: "secret-v",
secret: true,
});
// None of the four build settings are emitted for an app that declares none:
// managing is_static is opt-in (declaring it would otherwise PATCH static
// serving OFF on an un-migrated app), and the commands default to "let the
// build pack decide".
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
});
it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /, static: false }
domains: ["https://c.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields.is_static).toBe(false);
});
// #63: the static-site build settings a workspace monorepo needs.
it("emits is_static:true and the three commands for a non-compose app that declares them", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
landing:
source: { repo: acme/widget, branch: main }
build:
pack: static
base_directory: /
publish_directory: /apps/landing-site/dist
install_command: npm ci
build_command: npm run build -w apps/landing-site
start_command: node server.js
static: true
domains: ["https://landing.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toMatchObject({
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
start_command: "node server.js",
publish_directory: "/apps/landing-site/dist",
});
});
// The reverse of what this file used to assert. `backup` was deliberately
// routed AROUND `fields` into a side channel, because live Coolify was
// believed not to expose a schedule back; it does (GET
// /databases/{uuid}/backups), and the side channel is what made a `backup:`
// block added to an existing database silently do nothing (#51).
it("puts a database backup block in fields, so it is diffed like any other", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
databases:
postgres:
type: postgresql
version: "17"
backup: { frequency: "0 3 * * *", retention: 7 }
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toEqual({
type: "postgresql",
version: "17",
backup: { frequency: "0 3 * * *", retention: 7 },
});
});
it("leaves `backup` out of fields entirely when none is declared", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
databases:
postgres:
type: postgresql
version: "17"
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toEqual({ type: "postgresql", version: "17" });
// Undeclared means uncompared, NOT "delete whatever is there": a live
// schedule on a database whose manifest says nothing about backups is left
// alone, like every other thing apply never removes.
expect("backup" in desired[0].fields).toBe(false);
});
it("emits a service's service_domains into fields, canonicalized (cast#72)", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
service_domains:
umami: ["https://b.example.com", "https://a.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
// Keys and each URL array are sorted so container order never false-drifts
// against Coolify's read-back ordering.
expect(desired[0].fields).toEqual({
type: "umami",
service_domains: {
umami: ["https://a.example.com", "https://b.example.com"],
},
});
});
it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
service_domains:
umami: ["https://analytics.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: {
type: "umami",
service_domains: { umami: ["https://analytics.example.com"] },
},
},
],
"full",
);
expect(report.clean).toBe(true);
});
it("diffs a service whose declared hostname is missing live (apply will set it)", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
service_domains:
umami: ["https://analytics.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "prod", {});
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: { type: "umami" },
},
],
"full",
);
expect(report.clean).toBe(false);
expect(report.changes[0].fieldDiffs).toEqual([
{
field: "service_domains",
desired: { umami: ["https://analytics.example.com"] },
live: undefined,
updatable: true,
},
]);
});
it("a service with no service_domains carries only its type", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
services:
plausible:
type: plausible
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toEqual({ type: "plausible" });
});
it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.widget.example.com"]
env_template: core.prod.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", "core.prod.env.template"),
"PORT=3000\n",
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "/docker-compose.yaml",
docker_compose_domains: {
api: ["https://api.widget.example.com"],
},
},
});
expect(desired[0].fields).not.toHaveProperty("port");
expect(desired[0].fields).not.toHaveProperty("healthcheck");
expect(desired[0].fields).not.toHaveProperty("domains");
// A compose app builds from its compose file — none of the static/command
// fields belong on it, not even is_static (which every NON-compose app gets).
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
});
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.widget.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/application core/);
expect(warn.mock.calls[0][0]).toMatch(/Include Source Commit in Build/);
expect(warn.mock.calls[0][0]).toMatch(/Coolify UI/);
warn.mockRestore();
// The setting is absent from Coolify 4.1.2's create/PATCH allowlists, which
// reject unknown keys outright — so it must never reach `fields`, or apply
// would 422 on every run. Guards the fix a future reader would reach for.
expect(desired[0].fields).not.toHaveProperty(
"include_source_commit_in_build",
);
});
it("does not warn about the source-commit toggle for a non-dockercompose app (the build arg is a compose concern)", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
site:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://widget.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
desiredFromManifest(dir, "prod", {});
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("throws when the env is missing from the manifest", () => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
"project: x\nenvironments: {}\n",
);
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/environment prod not in manifest/,
);
});
});
describe("derived resource refs (#60)", () => {
// A manifest with one app whose template derives a DB URL, plus the database
// the ref names. `dbName` and `attr` are knobs the validation cases turn.
const write = (
ref = "${resource:postgres.url}",
dbBlock = " databases:\n postgres: { type: postgresql }\n",
): string => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
domains: ["http://api.example.com"]
env_template: core.staging.env.template
${dbBlock}`,
);
writeFileSync(
join(dir, ".infra", "env", "core.staging.env.template"),
`DATABASE_URL=${ref}\n`,
);
return dir;
};
it("emits a derived var (unresolved) and does not demand it as a secret", () => {
const dir = write();
const { desired } = desiredFromManifest(dir, "staging", {});
const app = desired.find((d) => d.name === "core");
expect(app?.env?.vars.DATABASE_URL).toEqual({
value: DERIVED_UNRESOLVED,
secret: true,
derived: { resource: "postgres", attr: "url" },
});
// capture's view: it is NOT a required store secret.
const req = requiredSecrets(dir, "staging");
expect(req.required.map((r) => r.ref)).not.toContain(
"resource:postgres.url",
);
expect(req.required).toHaveLength(0);
});
it("fillDesiredDerived fills it from a URL map keyed by manifest name", () => {
const dir = write();
const { desired } = desiredFromManifest(dir, "staging", {});
const filled = fillDesiredDerived(desired, {
postgres: "postgres://u:p@uuid:5432/db",
});
const app = filled.find((d) => d.name === "core");
expect(app?.env?.vars.DATABASE_URL.value).toBe(
"postgres://u:p@uuid:5432/db",
);
});
it("hard-refuses a ref naming a database the manifest does not declare", () => {
// No databases block at all — the ref points at nothing.
const dir = write("${resource:postgres.url}", "");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/no database named postgres/,
);
// capture refuses it too, in the same voice — every verb that opens a template.
expect(() => requiredSecrets(dir, "staging")).toThrow(
/no database named postgres/,
);
});
it("hard-refuses an attribute other than .url", () => {
const dir = write("${resource:postgres.password}");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/unknown resource attribute/,
);
});
});
describe("derived domain refs (#66)", () => {
// Assemble a manifest from an applications block plus one env template. The
// env_template line is appended to whichever app comes last in `apps`.
const write = (apps: string, tmpl: string): string => {
const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
${apps}`,
);
writeFileSync(join(dir, ".infra", "env", "refs.env.template"), tmpl);
return dir;
};
// A plain app (a `domains` list) and a compose app (`service_domains`); the
// env_template line appended after either makes that app carry the template.
const LANDING = ` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://new.heavyduty.builders"]
`;
const CORE = ` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: ["https://admin.heavyduty.builders"]
`;
const TMPL = " env_template: refs.env.template\n";
it("resolves ${domain:<app>} and ${domain:<app>.<service>} to the manifest's domains, secret:false", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\n",
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired, resolvedEnvs } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
const core = desired.find((d) => d.name === "core");
// The app.service ref and the app ref both resolve to the verbatim domain
// (scheme and all), public, and with no `domain` marker — a plain literal.
expect(core?.env?.vars.ADMIN_WEB_BASE_URL).toEqual({
value: "https://admin.heavyduty.builders",
secret: false,
});
expect(core?.env?.vars.LANDING_BASE_URL).toEqual({
value: "https://new.heavyduty.builders",
secret: false,
});
// resolvedEnvs is domain-filled too, and no sentinel escapes anywhere.
expect(resolvedEnvs.core.vars.LANDING_BASE_URL.value).toBe(
"https://new.heavyduty.builders",
);
expect(JSON.stringify(desired)).not.toContain("cast:unresolved-domain-ref");
});
it("does not list domain refs as required secrets (capture validates but never captures them)", () => {
const dir = write(
LANDING + CORE + TMPL,
"ADMIN_WEB_BASE_URL=${domain:core.admin}\nLANDING_BASE_URL=${domain:landing}\nMG=${MG}\n",
);
const req = requiredSecrets(dir, "prod");
// Only the real ${MG} secret is required — the two domain refs are not.
expect(req.required.map((r) => r.ref)).toEqual(["MG"]);
});
it("refuses a ref naming an application the manifest does not declare", () => {
const dir = write(LANDING + TMPL, "X=${domain:nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/no application named nope/,
);
// Every verb that opens a template refuses it, in the same voice.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/no application named nope/,
);
});
it("refuses ${domain:<app>} on a compose app whose domains live per service", () => {
const dir = write(CORE + TMPL, "X=${domain:core}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(
/domains live per service/,
);
});
it("refuses ${domain:<app>.<service>} on an app that declares a plain domains list", () => {
const dir = write(LANDING + TMPL, "X=${domain:landing.admin}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
it("refuses a service the app's service_domains does not declare", () => {
const dir = write(CORE + TMPL, "X=${domain:core.nope}\n");
expect(() => requiredSecrets(dir, "prod")).toThrow(/no service named nope/);
});
it("refuses a ref whose selected domain list is declared but empty", () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/domain list is empty/);
});
it('refuses a ref whose selected list has a blank first entry (domains: [""]) — the sentinel must not escape', () => {
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: [""]
${TMPL}`,
"X=${domain:landing}\n",
);
// Schema-valid (a non-empty array of strings), so it PASSES manifest load —
// the assert is the gate. buildDomainMap would store "" and fillDomainEnv
// would read "" as unresolved, leaving DOMAIN_UNRESOLVED in a returned env.
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
// Every verb that opens a template refuses it — the sentinel never escapes
// into a returned desired set.
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/empty or its first entry is blank/,
);
});
it('refuses a service ref whose selected list has a blank first entry (service_domains: {admin: [""]})', () => {
const dir = write(
` core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
admin: [""]
${TMPL}`,
"X=${domain:core.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(
/empty or its first entry is blank/,
);
});
it("gives the domains-app-shape message (not 'no service named') for a service ref against an empty-domains app", () => {
// An empty `domains: []` is still a domains app (shape is by key presence).
// A ${domain:app.svc} ref against it is a spurious-service error, not an
// unknown-service one.
const dir = write(
` landing:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: []
${TMPL}`,
"X=${domain:landing.admin}\n",
);
expect(() => requiredSecrets(dir, "prod")).toThrow(/plain `domains` list/);
});
});