cast/test/live-lookup.test.ts
claude-hdb 7586df453b fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.

Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.

There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.

- fetchLive returns a LiveLookup union, so absence is its own answer rather
  than a value that happens to equal "empty". diff refuses (exit 2) and names
  what it looked for, where that name came from, and what exists instead;
  apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
  that names it differently. It overrides ONLY that: secrets stay keyed by the
  repo, a state-repo convention we own.

#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.

Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00

136 lines
5 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { fetchLive, renderAbsentTarget } from "../src/cli.js";
import { CoolifyClient } from "../src/coolify.js";
// A Coolify that answers GET /projects with `projects`, and
// GET /projects/{uuid}/{env} with whatever `envByProject` holds for it
// (undefined → 404, which is how Coolify says "no such environment").
function coolify(
projects: Array<{ uuid: string; name: string }>,
envByProject: Record<string, unknown> = {},
): CoolifyClient {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = new URL(String(url)).pathname.replace("/api/v1", "");
if (path === "/projects") {
return new Response(JSON.stringify(projects), { status: 200 });
}
const m = path.match(/^\/projects\/([^/]+)\/(.+)$/);
if (m) {
const body = envByProject[`${m[1]}/${m[2]}`];
return body === undefined
? new Response(JSON.stringify({ message: "Not found." }), {
status: 404,
})
: new Response(JSON.stringify(body), { status: 200 });
}
return new Response("{}", { status: 500 });
}) as unknown as typeof fetch;
return new CoolifyClient("https://coolify.test", "tok", fetchImpl);
}
describe("fetchLive", () => {
it("returns the live resources when project and environment both exist", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/prod": {
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "db", uuid: "d1" }],
},
});
const r = await fetchLive(client, "incubator", "prod");
expect(r.found).toBe(true);
if (!r.found) throw new Error("unreachable");
expect(r.live.map((l) => l.name).sort()).toEqual(["core", "db"]);
});
// The bug this whole change exists for: an absent project used to come back
// as [], which computeDiff reads as "every desired resource is missing" and
// renders as a confident full-create plan. Absence must be its own answer,
// distinguishable from an empty-but-real environment.
it("reports an ABSENT project as absent, not as empty", async () => {
const client = coolify([
{ uuid: "p1", name: "incubator-prod" },
{ uuid: "p2", name: "umami" },
]);
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({
found: false,
missing: "project",
project: "incubator",
available: ["incubator-prod", "umami"],
});
});
// Same lie, a different road: the project is real but the environment name
// is not. A hand-built project is very often `production`, not `prod`.
it("reports an ABSENT environment as absent, not as empty", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/production": { applications: [] },
});
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({
found: false,
missing: "environment",
project: "incubator",
environment: "prod",
});
});
// The distinction has to be real in BOTH directions, or the gate would just
// trade a false pass for a false alarm: a project whose environment exists
// and is genuinely empty is `found`, with zero resources.
it("distinguishes a real-but-empty environment from an absent one", async () => {
const client = coolify([{ uuid: "p1", name: "incubator" }], {
"p1/prod": { applications: [], postgresqls: [], services: [] },
});
const r = await fetchLive(client, "incubator", "prod");
expect(r).toEqual({ found: true, live: [] });
});
});
describe("renderAbsentTarget", () => {
it("names what it looked for, where the name came from, and what exists", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "project",
project: "incubator",
available: ["incubator-prod", "umami"],
},
{ orgRepo: "heavy-duty/incubator", overridden: false },
);
expect(msg).toMatch(/no project named "incubator"/);
expect(msg).toMatch(/derived from the repo slug heavy-duty\/incubator/);
expect(msg).toMatch(/incubator-prod, umami/);
expect(msg).toMatch(/--project <name>/);
// The reader must not be able to walk away thinking a clean diff was a pass.
expect(msg).toMatch(/verified\s+nothing/);
});
it("says the name came from --project when it was overridden", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "project",
project: "typo",
available: ["incubator"],
},
{ orgRepo: "heavy-duty/incubator", overridden: true },
);
expect(msg).toMatch(/\(--project\)/);
});
it("points at the UI-naming gotcha when the environment is what is missing", () => {
const msg = renderAbsentTarget(
{
found: false,
missing: "environment",
project: "incubator",
environment: "prod",
},
{ orgRepo: "heavy-duty/incubator", overridden: false },
);
expect(msg).toMatch(/has no environment "prod"/);
expect(msg).toMatch(/production/);
expect(msg).toMatch(/--env/);
});
});