cast/test/inventory.test.ts

125 lines
4.5 KiB
TypeScript
Raw Normal View History

feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
import { describe, expect, it } from "vitest";
import { absentResources } from "../src/capture.js";
import { reconcile, renderInventory } from "../src/inventory.js";
import type { ManifestResource } from "../src/resolve.js";
const manifest: ManifestResource[] = [
{
kind: "application",
name: "core",
envKeys: ["NODE_ENV", "DATABASE_URL", "MAILGUN_API_KEY"],
},
{ kind: "service", name: "umami", envKeys: ["APP_SECRET"] },
{ kind: "database", name: "postgres", envKeys: [] },
];
describe("reconcile", () => {
it("sorts resources into both / manifest-only / box-only", () => {
const rec = reconcile(manifest, [
{ kind: "application", name: "core", envKeys: ["NODE_ENV"] },
{ kind: "application", name: "landing", envKeys: [] },
]);
expect(rec.matched.map((m) => m.name)).toEqual(["core"]);
expect(rec.manifestOnly.map((m) => m.name)).toEqual(["umami", "postgres"]);
expect(rec.boxOnly.map((l) => l.name)).toEqual(["landing"]);
});
it("splits a matched resource's env KEYS the same three ways", () => {
const rec = reconcile(manifest, [
{
kind: "application",
name: "core",
envKeys: ["NODE_ENV", "MAILGUN_API_KEY", "LEFTOVER_FROM_2019"],
},
]);
const core = rec.matched[0];
expect(core.sharedKeys).toEqual(["MAILGUN_API_KEY", "NODE_ENV"]);
// The manifest wants it; the box has never heard of it.
expect(core.manifestOnlyKeys).toEqual(["DATABASE_URL"]);
// On the box, unknown to the manifest. Either the manifest must gain it or
// it is cruft that must not travel — the judgment this verb exists to serve.
expect(core.boxOnlyKeys).toEqual(["LEFTOVER_FROM_2019"]);
});
it("matches by name across a kind mismatch rather than hiding it", () => {
// A manifest `service` the box models as an `application` is the same thing
// under two vocabularies. Reporting it as manifest-only + box-only would
// read as "two unrelated resources", which is exactly the wrong conclusion.
const rec = reconcile(
[{ kind: "service", name: "umami", envKeys: [] }],
[{ kind: "application", name: "umami", envKeys: [] }],
);
expect(rec.manifestOnly).toEqual([]);
expect(rec.boxOnly).toEqual([]);
expect(rec.matched[0].kind).toContain("service");
expect(rec.matched[0].kind).toContain("application");
});
});
describe("renderInventory", () => {
const ctx = {
orgRepo: "heavy-duty/incubator",
env: "prod",
instance: "box-b",
project: "Incubator",
environment: "production",
};
it("names both sides and every bucket", () => {
const out = renderInventory(
reconcile(manifest, [
{
kind: "application",
name: "incubator-stack",
envKeys: ["MAILGUN_API_KEY"],
},
]),
ctx,
);
// The two names that are NOT ours — the coordinates that made this readable.
expect(out).toContain("Incubator");
expect(out).toContain("production");
// Declared, absent.
expect(out).toContain("core");
// Present, undeclared — the finding that a MISSING-per-name report buries.
expect(out).toContain("incubator-stack");
expect(out).toContain("This is a document, not desired state");
});
it("treats a zero-drift hand-built box as suspicious, not as a pass", () => {
const out = renderInventory(
reconcile(
[{ kind: "application", name: "core", envKeys: [] }],
[{ kind: "application", name: "core", envKeys: [] }],
),
ctx,
);
expect(out).toContain("suspicion rather than relief");
});
});
describe("absentResources", () => {
const required = [
{ ref: "MAILGUN_API_KEY", resource: "core", key: "MAILGUN_API_KEY" },
{ ref: "DATABASE_URL_PROD", resource: "core", key: "DATABASE_URL" },
{ ref: "UMAMI_APP_SECRET", resource: "umami", key: "APP_SECRET" },
];
it("names the manifest resources the box does not have", () => {
expect(absentResources(required, ["incubator-stack", "umami"])).toEqual([
"core",
]);
});
it("is empty when every declaring resource exists", () => {
expect(absentResources(required, ["core", "umami", "landing"])).toEqual([]);
});
it("reports each absent resource once, not once per secret it declares", () => {
// `core` declares two of the three refs. The old failure reported one
// MISSING per NAME (15 of them, in the live incident) and buried the single
// fact that mattered: one resource is called something else here.
expect(absentResources(required, [])).toEqual(["core", "umami"]);
});
});