`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>
62 lines
2.5 KiB
TypeScript
62 lines
2.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { type Bindings, githubAppNameFor } from "../src/bindings.js";
|
|
|
|
function bindings(github_apps: Record<string, string>): Bindings {
|
|
return {
|
|
environments: {
|
|
prod: { server: "prod-box", team: { id: 0, name: "Root Team" } },
|
|
},
|
|
github_apps,
|
|
} as Bindings;
|
|
}
|
|
|
|
describe("githubAppNameFor", () => {
|
|
it("resolves the full <org>/<repo> slug", () => {
|
|
const b = bindings({ "heavy-duty/incubator": "hdb-coolify" });
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
// Every state file written before full-slug keying uses the bare repo name.
|
|
// Dropping that would break them for no gain, so it stays as a fallback.
|
|
it("still resolves a legacy bare <repo> key", () => {
|
|
const b = bindings({ incubator: "hdb-coolify" });
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
// The whole point of the issue. A short name is unique only *within* an org,
|
|
// so two orgs' same-named repos collapse onto one key — and the loser gets
|
|
// cloned by the winner's App, silently, because a wrong-but-existing App
|
|
// still resolves to a real uuid and the create succeeds.
|
|
it("keeps two orgs' same-named repos on separate Apps", () => {
|
|
const b = bindings({
|
|
"heavy-duty/incubator": "hdb-coolify",
|
|
"acme/incubator": "acme-coolify",
|
|
});
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
expect(githubAppNameFor(b, "acme/incubator")).toBe("acme-coolify");
|
|
});
|
|
|
|
// Precedence matters in exactly the case that motivated the fix: a state file
|
|
// mid-migration carries both a legacy short key and a new full-slug one. The
|
|
// slug is the thing that actually identifies a repo, so it must win.
|
|
it("prefers the full slug over a colliding bare key", () => {
|
|
const b = bindings({
|
|
incubator: "legacy-app",
|
|
"heavy-duty/incubator": "hdb-coolify",
|
|
});
|
|
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
|
|
});
|
|
|
|
it("refuses an unbound repo, naming both keys it tried", () => {
|
|
const b = bindings({ "heavy-duty/other": "other-app" });
|
|
const err = githubAppNameFor.bind(
|
|
null,
|
|
b,
|
|
"heavy-duty/incubator",
|
|
) as () => string;
|
|
expect(err).toThrow(/no GitHub App bound for heavy-duty\/incubator/);
|
|
expect(err).toThrow(/github_apps\["heavy-duty\/incubator"\]/);
|
|
expect(err).toThrow(/github_apps\["incubator"\]/);
|
|
expect(err).toThrow(/heavy-duty\/other/);
|
|
});
|
|
});
|