125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
|
|
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"]);
|
||
|
|
});
|
||
|
|
});
|