cast/test/capture.test.ts

597 lines
21 KiB
TypeScript
Raw Normal View History

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-19 23:38:53 +00:00
import { mkdirSync, writeFileSync } from "node:fs";
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
GENERATED_PLACEHOLDER,
feat(capture): --generated-only, the bootstrap's missing pass 2 A manifest that declares `generated_secrets:` bootstraps in two passes by construction: pass 1 `capture` placeholds those names (their values do not exist yet), `apply` creates the database and Coolify generates the real URL — and nothing then taught the store that value. The operator did it by hand: decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's age recipient, against production, holding the prod key. `capture --generated-only` inverts capture's disposition rule and changes nothing else — it fills the generated names and leaves every other name in the store exactly as it is, byte for byte. Same verb, same ceremony, same store-writing code path. - reads the value from the resource that OWNS it (`internal_db_url` on the database), never from a consuming app's env, where a generated URL never appears — the app's env holds the placeholder itself at this point. - resolves the database inside the project+environment via GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which lists other projects' databases and umami's bundled Postgres — #29's bug in another hat). The scoping is structural, not a filter. - refuses to guess which database a name comes from: nothing in the manifest, the templates or the box carries that edge, so it infers only when it cannot be wrong (one name, one database) and otherwise hands back `--from`. - refuses to overwrite a generated name holding a real value without --force (a silent credential rotation), a name absent from the store, and a placeholder nothing fills. - asserts the postcondition against the ciphertext on disk: zero pending-coolify-generated remain, and the name count is unchanged. That assertion was a line in a human runbook. `apply` deliberately does NOT do this after a create — it would make the verb that mutates Coolify also mutate the encrypted store, and hence the git repo. Closes #48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
type GeneratedSource,
assertGeneratedComplete,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
classify,
feat(capture): --generated-only, the bootstrap's missing pass 2 A manifest that declares `generated_secrets:` bootstraps in two passes by construction: pass 1 `capture` placeholds those names (their values do not exist yet), `apply` creates the database and Coolify generates the real URL — and nothing then taught the store that value. The operator did it by hand: decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's age recipient, against production, holding the prod key. `capture --generated-only` inverts capture's disposition rule and changes nothing else — it fills the generated names and leaves every other name in the store exactly as it is, byte for byte. Same verb, same ceremony, same store-writing code path. - reads the value from the resource that OWNS it (`internal_db_url` on the database), never from a consuming app's env, where a generated URL never appears — the app's env holds the placeholder itself at this point. - resolves the database inside the project+environment via GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which lists other projects' databases and umami's bundled Postgres — #29's bug in another hat). The scoping is structural, not a filter. - refuses to guess which database a name comes from: nothing in the manifest, the templates or the box carries that edge, so it infers only when it cannot be wrong (one name, one database) and otherwise hands back `--from`. - refuses to overwrite a generated name holding a real value without --force (a silent credential rotation), a name absent from the store, and a placeholder nothing fills. - asserts the postcondition against the ciphertext on disk: zero pending-coolify-generated remain, and the name count is unchanged. That assertion was a line in a human runbook. `apply` deliberately does NOT do this after a create — it would make the verb that mutates Coolify also mutate the encrypted store, and hence the git repo. Closes #48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
generatedPlanRefuses,
planGenerated,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
renderCapturePlan,
feat(capture): --generated-only, the bootstrap's missing pass 2 A manifest that declares `generated_secrets:` bootstraps in two passes by construction: pass 1 `capture` placeholds those names (their values do not exist yet), `apply` creates the database and Coolify generates the real URL — and nothing then taught the store that value. The operator did it by hand: decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's age recipient, against production, holding the prod key. `capture --generated-only` inverts capture's disposition rule and changes nothing else — it fills the generated names and leaves every other name in the store exactly as it is, byte for byte. Same verb, same ceremony, same store-writing code path. - reads the value from the resource that OWNS it (`internal_db_url` on the database), never from a consuming app's env, where a generated URL never appears — the app's env holds the placeholder itself at this point. - resolves the database inside the project+environment via GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which lists other projects' databases and umami's bundled Postgres — #29's bug in another hat). The scoping is structural, not a filter. - refuses to guess which database a name comes from: nothing in the manifest, the templates or the box carries that edge, so it infers only when it cannot be wrong (one name, one database) and otherwise hands back `--from`. - refuses to overwrite a generated name holding a real value without --force (a silent credential rotation), a name absent from the store, and a placeholder nothing fills. - asserts the postcondition against the ciphertext on disk: zero pending-coolify-generated remain, and the name count is unchanged. That assertion was a line in a human runbook. `apply` deliberately does NOT do this after a create — it would make the verb that mutates Coolify also mutate the encrypted store, and hence the git repo. Closes #48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
renderGeneratedPlan,
resolveGeneratedSources,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
} from "../src/capture.js";
import { requiredSecrets } from "../src/resolve.js";
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-19 23:38:53 +00:00
import { tmp } from "./helpers/tmp.js";
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
const CTX = {
orgRepo: "heavy-duty/incubator",
env: "prod",
instance: "legacy",
store: "/s/secrets/incubator.prod.env.age",
recipient: "age1abc",
};
// The live case, shrunk: an app whose env template needs a generated database
// URL, a carried-over API key, and an address that must NOT be carried over.
const REQUIRED = [
{ ref: "DATABASE_URL_PROD", resource: "core", key: "DATABASE_URL" },
{ ref: "MAILGUN_API_KEY", resource: "core", key: "MAILGUN_API_KEY" },
{ ref: "ADMIN_EMAIL", resource: "core", key: "ADMIN_EMAIL" },
];
const LIVE = {
core: {
DATABASE_URL: "postgres://SOURCE-BOX-INTERNAL/db",
MAILGUN_API_KEY: "key-abc123-REAL-SECRET",
ADMIN_EMAIL: "founder@real-company.com",
},
};
describe("classify", () => {
it("captures a live value, and records where it came from", () => {
const c = classify([REQUIRED[1]], [], LIVE, {});
expect(c.plan).toEqual([
{
ref: "MAILGUN_API_KEY",
provenance: "captured",
value: "key-abc123-REAL-SECRET",
sites: [{ resource: "core", key: "MAILGUN_API_KEY" }],
},
]);
});
// The failure this verb exists to prevent: the source box's DATABASE_URL
// points at the SOURCE box's Postgres. Copying it over is confidently wrong
// in a way that looks entirely plausible.
it("placeholds a generated name, never copying the source's value", () => {
const c = classify([REQUIRED[0]], ["DATABASE_URL_PROD"], LIVE, {});
expect(c.plan[0]).toMatchObject({
ref: "DATABASE_URL_PROD",
provenance: "generated",
value: GENERATED_PLACEHOLDER,
});
expect(c.plan[0].value).not.toContain("SOURCE-BOX");
});
// staging and prod share a Mailgun domain, so a staging box carrying the
// real ADMIN_EMAIL can mail real users.
it("takes an override from the operator, over the source's value", () => {
const c = classify([REQUIRED[2]], [], LIVE, {
ADMIN_EMAIL: "operator@example.com",
});
expect(c.plan[0]).toMatchObject({
provenance: "overridden",
value: "operator@example.com",
});
});
it("an override beats a generated declaration too", () => {
const c = classify([REQUIRED[0]], ["DATABASE_URL_PROD"], LIVE, {
DATABASE_URL_PROD: "postgres://explicit",
});
expect(c.plan[0]).toMatchObject({
provenance: "overridden",
value: "postgres://explicit",
});
});
// Required by the template, absent from the source: refuse rather than write
// an empty. An empty substitutes to nothing and the app boots misconfigured.
it("refuses on a name required by the template but absent from the source", () => {
const c = classify(
[{ ref: "TURNSTILE_SECRET", resource: "core", key: "TURNSTILE_SECRET" }],
[],
LIVE,
{},
);
expect(c.plan).toEqual([]);
expect(c.missing).toEqual([
{
ref: "TURNSTILE_SECRET",
sites: [{ resource: "core", key: "TURNSTILE_SECRET" }],
},
]);
});
it("a missing name can be rescued by an override", () => {
const c = classify(
[{ ref: "TURNSTILE_SECRET", resource: "core", key: "TURNSTILE_SECRET" }],
[],
LIVE,
{ TURNSTILE_SECRET: "supplied" },
);
expect(c.missing).toEqual([]);
expect(c.plan[0].provenance).toBe("overridden");
});
// One name, two resources, two different live values. The store holds one
// value per name; picking wrong would be silent.
it("refuses when one name carries different values on two resources", () => {
const c = classify(
[
{ ref: "SHARED", resource: "core", key: "SHARED" },
{ ref: "SHARED", resource: "worker", key: "SHARED" },
],
[],
{ core: { SHARED: "a" }, worker: { SHARED: "b" } },
{},
);
expect(c.plan).toEqual([]);
expect(c.conflicts).toEqual([
{
ref: "SHARED",
values: [
{ resource: "core", key: "SHARED" },
{ resource: "worker", key: "SHARED" },
],
},
]);
});
it("is fine when one name carries the SAME value on two resources", () => {
const c = classify(
[
{ ref: "SHARED", resource: "core", key: "SHARED" },
{ ref: "SHARED", resource: "worker", key: "SHARED" },
],
[],
{ core: { SHARED: "same" }, worker: { SHARED: "same" } },
{},
);
expect(c.conflicts).toEqual([]);
expect(c.plan[0].value).toBe("same");
});
// The acceptance criterion: exactly the names the manifest requires, no more
// and no fewer. A live var the manifest does not ask for is not the store's
// business.
it("writes exactly the required names — ignoring live vars nobody asked for", () => {
const c = classify(
REQUIRED,
["DATABASE_URL_PROD"],
{
core: { ...LIVE.core, SOME_OTHER_LIVE_VAR: "not in the manifest" },
},
{},
);
expect(c.plan.map((d) => d.ref).sort()).toEqual([
"ADMIN_EMAIL",
"DATABASE_URL_PROD",
"MAILGUN_API_KEY",
]);
});
});
describe("renderCapturePlan", () => {
// THE invariant. "No secret value is ever written to stdout" — so the plan
// is names and provenance, and the test asserts on the actual live values
// rather than on a pattern that could drift away from them.
it("never prints a secret value", () => {
const c = classify(REQUIRED, ["DATABASE_URL_PROD"], LIVE, {
ADMIN_EMAIL: "operator@example.com",
});
const out = renderCapturePlan(c, CTX);
for (const secret of [
"postgres://SOURCE-BOX-INTERNAL/db",
"key-abc123-REAL-SECRET",
"founder@real-company.com",
"operator@example.com",
]) {
expect(out).not.toContain(secret);
}
});
it("shows every name with its provenance and where it lands", () => {
const c = classify(REQUIRED, ["DATABASE_URL_PROD"], LIVE, {
ADMIN_EMAIL: "operator@example.com",
});
const out = renderCapturePlan(c, CTX);
expect(out).toMatch(/MAILGUN_API_KEY\s+captured\s+core\.MAILGUN_API_KEY/);
expect(out).toMatch(/DATABASE_URL_PROD\s+generated/);
expect(out).toContain(GENERATED_PLACEHOLDER);
expect(out).toMatch(/ADMIN_EMAIL\s+overridden/);
expect(out).toContain("CAST_CAPTURE_ADMIN_EMAIL");
expect(out).toMatch(/3 name\(s\) to write/);
expect(out).toContain("/s/secrets/incubator.prod.env.age");
expect(out).toContain("age1abc");
});
it("names what is missing, and says why an empty would be worse", () => {
const c = classify(
[{ ref: "TURNSTILE_SECRET", resource: "core", key: "TURNSTILE_SECRET" }],
[],
LIVE,
{},
);
const out = renderCapturePlan(c, CTX);
expect(out).toMatch(/TURNSTILE_SECRET\s+MISSING/);
expect(out).toMatch(/refusing to write the store/);
expect(out).toMatch(/--override/);
});
it("names a conflict rather than picking a side", () => {
const c = classify(
[
{ ref: "SHARED", resource: "core", key: "SHARED" },
{ ref: "SHARED", resource: "worker", key: "SHARED" },
],
[],
{ core: { SHARED: "a" }, worker: { SHARED: "b" } },
{},
);
const out = renderCapturePlan(c, CTX);
expect(out).toMatch(/SHARED\s+CONFLICT/);
expect(out).toMatch(/core\.SHARED and worker\.SHARED/);
expect(out).not.toMatch(/\ba\b.*\bb\b/);
});
});
// requiredSecrets is what makes "no more, no fewer" true: the set comes from
// the manifest's own templates, read by the same parser apply uses.
describe("requiredSecrets", () => {
function checkout(manifest: string, templates: Record<string, string>) {
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-19 23:38:53 +00:00
const dir = tmp("cast-cap-");
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest);
for (const [name, body] of Object.entries(templates)) {
writeFileSync(join(dir, ".infra", "env", name), body);
}
return dir;
}
const MANIFEST = `project: incubator
environments:
prod:
generated_secrets: [DATABASE_URL_PROD]
applications:
core:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15) cast was scoped to the steady state: manifest → Coolify, forever. It had no adoption path — no way to bootstrap the age store from an instance built by hand, before any manifest existed. The operator did it by hand: curl the envs, assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input to that pipeline is something cast already has, so a human was shuffling cast's own inputs through a terminal, with the leak (scrollback, history, a tmp file that never got shredded) and the silent miss both live. cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force] The required set comes from the MANIFEST, not the box: the ${...} refs in that environment's env templates, read by the same parser apply uses to demand them. resolveTemplate and templateRefs now share one grammar — a drift between them would mean capture collects a different set than apply later requires, which is exactly the "a name silently missed" failure this verb exists to remove. The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify creates the resource. So the manifest declares `generated_secrets:` and those names are written as the literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator, not the source's — staging and prod share a Mailgun domain, so a staging box carrying the real address can mail real users; that is --override. A "capture everything" verb would be wrong in ~4 of 17 entries, silently — worse than being wrong in all of them. So every name is forced into a disposition, and two of the four stop the run: a name required by a template but absent from the source REFUSES (an empty substitutes to nothing and the app boots misconfigured), as does one name carrying different values on two resources. generated_secrets is a manifest property rather than a flag the operator must remember, because the manifest is what knows DATABASE_URL comes from a database it declares. An entry no template refers to is a hard error: a guard standing over nothing reads like a guard, and the likeliest cause is a typo whose real name is then captured from the source instead of placeheld. Secret hygiene, all covered by tests asserting on real values: - the plan prints names and provenance, NEVER values - an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`) - plaintext is piped to age on stdin — never a temp file, stdout, or history - an existing store is not overwritten without --force: it may hold the only copy of values the source no longer has (apply's never-delete, applied here) capture inherits diff's absent-target refusal (D-237) — against a project that isn't there it would report every secret as missing, an alarming report about the wrong box — plus the team assert and the --path/--env prod ban. The last gate is a typed confirmation of the environment's name; there is no --yes. The end-to-end test decrypts the store cast wrote and asserts on its contents, so "exactly the names the manifest requires, no more and no fewer" is checked against real ciphertext rather than against cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
service_domains:
api: ["https://api.example.com"]
env_template: core.prod.env.template
services:
umami:
type: umami
env_template: umami.prod.env.template
`;
it("collects the ${...} refs from every app and service template", () => {
const dir = checkout(MANIFEST, {
"core.prod.env.template":
"NODE_ENV=production\nDATABASE_URL=${DATABASE_URL_PROD}\nMAILGUN_API_KEY=${MAILGUN_API_KEY}\n",
"umami.prod.env.template": "APP_SECRET=${UMAMI_APP_SECRET}\n",
});
const { required, generated } = requiredSecrets(dir, "prod");
expect(required).toEqual([
{ ref: "DATABASE_URL_PROD", resource: "core", key: "DATABASE_URL" },
{ ref: "MAILGUN_API_KEY", resource: "core", key: "MAILGUN_API_KEY" },
{ ref: "UMAMI_APP_SECRET", resource: "umami", key: "APP_SECRET" },
]);
expect(generated).toEqual(["DATABASE_URL_PROD"]);
});
// A non-placeholder line (NODE_ENV=production) is not a secret and must not
// land in the store — the store holds the ${...} refs, nothing else.
it("ignores literal template values — only ${...} refs are secrets", () => {
const dir = checkout(MANIFEST, {
"core.prod.env.template":
"NODE_ENV=production\nREPORTING_ENABLED=false\nDATABASE_URL=${DATABASE_URL_PROD}\n",
"umami.prod.env.template": "",
});
const { required } = requiredSecrets(dir, "prod");
expect(required.map((r) => r.ref)).toEqual(["DATABASE_URL_PROD"]);
});
// Dead config in THIS list is dangerous, not merely untidy: it reads like a
// guard standing over a name while standing over nothing, and the likeliest
// cause is a typo whose real name is then CAPTURED from the source box.
it("refuses a generated_secrets entry that no template refers to", () => {
const dir = checkout(
MANIFEST.replace(
"generated_secrets: [DATABASE_URL_PROD]",
"generated_secrets: [DATABASE_URL_TYPO]",
),
{
"core.prod.env.template": "DATABASE_URL=${DATABASE_URL_PROD}\n",
"umami.prod.env.template": "",
},
);
expect(() => requiredSecrets(dir, "prod")).toThrow(
/generated_secrets names DATABASE_URL_TYPO/,
);
});
});
feat(capture): --generated-only, the bootstrap's missing pass 2 A manifest that declares `generated_secrets:` bootstraps in two passes by construction: pass 1 `capture` placeholds those names (their values do not exist yet), `apply` creates the database and Coolify generates the real URL — and nothing then taught the store that value. The operator did it by hand: decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's age recipient, against production, holding the prod key. `capture --generated-only` inverts capture's disposition rule and changes nothing else — it fills the generated names and leaves every other name in the store exactly as it is, byte for byte. Same verb, same ceremony, same store-writing code path. - reads the value from the resource that OWNS it (`internal_db_url` on the database), never from a consuming app's env, where a generated URL never appears — the app's env holds the placeholder itself at this point. - resolves the database inside the project+environment via GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which lists other projects' databases and umami's bundled Postgres — #29's bug in another hat). The scoping is structural, not a filter. - refuses to guess which database a name comes from: nothing in the manifest, the templates or the box carries that edge, so it infers only when it cannot be wrong (one name, one database) and otherwise hands back `--from`. - refuses to overwrite a generated name holding a real value without --force (a silent credential rotation), a name absent from the store, and a placeholder nothing fills. - asserts the postcondition against the ciphertext on disk: zero pending-coolify-generated remain, and the name count is unchanged. That assertion was a line in a human runbook. `apply` deliberately does NOT do this after a create — it would make the verb that mutates Coolify also mutate the encrypted store, and hence the git repo. Closes #48. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
// ---------------------------------------------------------------------------
// Pass 2 — capture --generated-only
// ---------------------------------------------------------------------------
// The live shape this verb exists for: the incubator, mid-bootstrap. Two
// databases in the project+environment, and a third Postgres that belongs to
// umami — the row the hand-run `jq | head` had to be careful not to pick, and
// the one a name-directed lookup across `GET /databases` would eventually take.
const PG: GeneratedSource = {
resource: "incubator-db",
type: "postgresql",
url: "postgres://u:REAL-PG-PASSWORD@abc123:5432/app",
};
const REDIS: GeneratedSource = {
resource: "incubator-redis",
type: "redis",
url: "redis://default:REAL-REDIS-PASSWORD@def456:6379/0",
};
// Pass 1 left these two placeheld; every other name is real and must survive.
const STORE = {
DATABASE_URL: GENERATED_PLACEHOLDER,
REDIS_URL: GENERATED_PLACEHOLDER,
MAILGUN_API_KEY: "key-REAL",
ADMIN_EMAIL: "operator@example.com",
};
const GENERATED = ["DATABASE_URL", "REDIS_URL"];
const GCTX = {
orgRepo: "heavy-duty/incubator",
env: "prod",
instance: "default",
store: "/s/secrets/incubator.prod.env.age",
recipient: "age1abc",
project: "incubator",
environment: "production",
};
// The mapping is stated, then the plan is built from it.
const planWith = (
from: Record<string, string>,
store = STORE,
databases = [PG, REDIS],
opts?: { force: boolean },
) => {
const { mapping, unmapped } = resolveGeneratedSources(
GENERATED,
databases,
from,
);
return planGenerated(GENERATED, store, mapping, unmapped, opts);
};
describe("resolveGeneratedSources", () => {
// The whole point: the value comes off the DATABASE, not off an app.
it("maps each generated name to the database the operator named", () => {
const { mapping, unmapped } = resolveGeneratedSources(
GENERATED,
[PG, REDIS],
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
);
expect(unmapped).toEqual([]);
expect(mapping.DATABASE_URL).toEqual(PG);
expect(mapping.REDIS_URL).toEqual(REDIS);
});
// #29 wearing a different hat. Nothing anywhere says DATABASE_URL comes from
// the postgres one — inferring it from the NAME is exactly the silent wrong
// pick this refuses, because what it writes is a well-formed URL to somebody
// else's database.
it("refuses to guess when more than one database could be meant", () => {
const { mapping, unmapped } = resolveGeneratedSources(
GENERATED,
[PG, REDIS],
{},
);
expect(mapping).toEqual({});
expect(unmapped.map((u) => u.ref)).toEqual(["DATABASE_URL", "REDIS_URL"]);
expect(unmapped[0].why).toMatch(/will not pick by name/);
// It names the candidates rather than picking one.
expect(unmapped[0].why).toContain("incubator-db:postgresql");
expect(unmapped[0].why).toContain("incubator-redis:redis");
});
// The one inference that cannot be wrong: nothing else it could be.
it("infers the only database when there is exactly one, for one name", () => {
const { mapping, unmapped } = resolveGeneratedSources(
["DATABASE_URL"],
[PG],
{},
);
expect(unmapped).toEqual([]);
expect(mapping.DATABASE_URL).toEqual(PG);
});
// ...and still refuses two names against that one database: filling REDIS_URL
// from the Postgres would be a perfectly well-formed lie.
it("does not infer when one database must serve two generated names", () => {
const { unmapped } = resolveGeneratedSources(GENERATED, [PG], {});
expect(unmapped.map((u) => u.ref)).toEqual(["DATABASE_URL", "REDIS_URL"]);
});
it("refuses a --from naming a database that is not in this project+env", () => {
const { unmapped } = resolveGeneratedSources(GENERATED, [PG, REDIS], {
DATABASE_URL: "umami-db",
REDIS_URL: "incubator-redis",
});
expect(unmapped).toHaveLength(1);
expect(unmapped[0].why).toMatch(/no database named "umami-db" exists/);
});
it("refuses when the environment holds no database at all", () => {
const { unmapped } = resolveGeneratedSources(["DATABASE_URL"], [], {});
expect(unmapped[0].why).toMatch(/no database exists/);
});
});
describe("planGenerated", () => {
it("fills the generated names and keeps every other one byte for byte", () => {
const p = planWith({
DATABASE_URL: "incubator-db",
REDIS_URL: "incubator-redis",
});
expect(p.fills).toEqual([
{
ref: "DATABASE_URL",
from: { resource: "incubator-db", type: "postgresql" },
value: PG.url,
},
{
ref: "REDIS_URL",
from: { resource: "incubator-redis", type: "redis" },
value: REDIS.url,
},
]);
// Untouched — and NOT re-read from the box, which is what makes pass 2 safe
// to run against an environment whose other secrets were rotated by hand.
expect(p.kept).toEqual(["ADMIN_EMAIL", "MAILGUN_API_KEY"]);
expect(p.occupied).toEqual([]);
expect(p.absent).toEqual([]);
expect(p.stillPending).toEqual([]);
});
// The refusal that stops a silent credential rotation: someone already filled
// it (a previous pass 2, or by hand) and the value is live.
it("refuses to overwrite a generated name that already holds a real value", () => {
const p = planWith(
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
{ ...STORE, DATABASE_URL: "postgres://already:filled@live/db" },
);
expect(p.occupied).toEqual(["DATABASE_URL"]);
expect(p.fills.map((f) => f.ref)).toEqual(["REDIS_URL"]);
expect(generatedPlanRefuses(p)).toBe(true);
});
it("--force fills it anyway, deliberately", () => {
const p = planWith(
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
{ ...STORE, DATABASE_URL: "postgres://already:filled@live/db" },
[PG, REDIS],
{ force: true },
);
expect(p.occupied).toEqual([]);
expect(p.fills.map((f) => f.ref)).toEqual(["DATABASE_URL", "REDIS_URL"]);
expect(generatedPlanRefuses(p)).toBe(false);
});
// Pass 2 FILLS names; it does not add them. A store missing one did not come
// from pass 1, and the name-count postcondition could not hold anyway.
it("refuses a generated name the store does not carry at all", () => {
const { REDIS_URL, ...withoutRedis } = STORE;
const p = planWith(
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
withoutRedis,
);
expect(p.absent).toEqual(["REDIS_URL"]);
expect(generatedPlanRefuses(p)).toBe(true);
});
// A placeholder standing in a name nothing here fills: the run would
// "succeed" and the store would still be a lie.
it("refuses when a name nobody fills would be left still pending", () => {
const p = planWith(
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
{ ...STORE, SESSION_SECRET: GENERATED_PLACEHOLDER },
);
expect(p.stillPending).toEqual(["SESSION_SECRET"]);
expect(generatedPlanRefuses(p)).toBe(true);
});
it("a refused name is not also reported as kept", () => {
const p = planWith({});
expect(p.kept).toEqual(["ADMIN_EMAIL", "MAILGUN_API_KEY"]);
expect(p.unmapped).toHaveLength(2);
});
});
describe("renderGeneratedPlan", () => {
// capture.ts:166's rule, unchanged: names, provenance, and the resource a
// value came FROM. The only value-shaped thing printed is the placeholder
// literal being replaced.
it("prints names and never a value", () => {
const out = renderGeneratedPlan(
planWith({ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" }),
GCTX,
);
expect(out).not.toContain("REAL-PG-PASSWORD");
expect(out).not.toContain("REAL-REDIS-PASSWORD");
expect(out).not.toContain("key-REAL");
expect(out).toContain("DATABASE_URL");
expect(out).toContain("incubator-db (postgresql) internal_db_url");
expect(out).toContain(GENERATED_PLACEHOLDER);
// The store is not being rewritten around the names it keeps, and says so.
expect(out).toMatch(/MAILGUN_API_KEY\s+keep/);
});
it("hands back a ready-to-paste --from when it will not guess", () => {
const out = renderGeneratedPlan(planWith({}), GCTX);
expect(out).toMatch(/refusing to write the store/);
expect(out).toContain("--from DATABASE_URL=<database name>");
expect(out).toContain("--from REDIS_URL=<database name>");
});
it("says a fill would rotate a live credential", () => {
const out = renderGeneratedPlan(
planWith(
{ DATABASE_URL: "incubator-db", REDIS_URL: "incubator-redis" },
{ ...STORE, DATABASE_URL: "postgres://live" },
),
GCTX,
);
expect(out).toMatch(/OCCUPIED/);
expect(out).toMatch(/rotate a live credential/);
expect(out).not.toContain("postgres://live");
});
});
// The assertion that was a line in a human runbook ("assert 14 names / zero
// placeholders"), which is to say a step that could be skipped.
describe("assertGeneratedComplete", () => {
it("passes when every placeholder is gone and the name set is unchanged", () => {
const after = { ...STORE, DATABASE_URL: PG.url, REDIS_URL: REDIS.url };
expect(assertGeneratedComplete(STORE, after)).toEqual([]);
});
it("catches a placeholder left standing", () => {
const after = { ...STORE, DATABASE_URL: PG.url };
const v = assertGeneratedComplete(STORE, after);
expect(v).toHaveLength(1);
expect(v[0]).toMatch(
/still hold the pending-coolify-generated literal: REDIS_URL/,
);
});
// A store that LOST a name re-encrypts perfectly and reads back perfectly.
// The failure surfaces at the next apply, in an environment whose plaintext
// nobody has any more.
it("catches a name dropped on the way through", () => {
const { MAILGUN_API_KEY, ...lost } = {
...STORE,
DATABASE_URL: PG.url,
REDIS_URL: REDIS.url,
};
const v = assertGeneratedComplete(STORE, lost);
expect(v.join(" ")).toMatch(/went in with 4 name\(s\) and came out with 3/);
expect(v.join(" ")).toMatch(/names LOST: MAILGUN_API_KEY/);
});
it("catches a name that was never supposed to be added", () => {
const after = {
...STORE,
DATABASE_URL: PG.url,
REDIS_URL: REDIS.url,
SURPRISE: "x",
};
expect(assertGeneratedComplete(STORE, after).join(" ")).toMatch(
/names ADDED: SURPRISE/,
);
});
});