cast/test/smoke-cli.test.ts
claude-hdb b07563a815 fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.

It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).

The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:08:35 +00:00

365 lines
13 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
// `cast smoke`, end to end, against the instance shape #29 is actually about.
//
// ONE Coolify, carrying:
//
// project incubator / environment prod → application `core` (a-prod-core)
// project incubator / environment staging → application `core` (a-staging-core)
// → database `db`
// project client-site / environment staging → application `core` (a-client-core)
//
// Three applications called `core`. That is not a contrived box — `instance:` is
// a per-environment binding, so with none set, prod and staging read the same
// .coolify.env and live on the same control plane. Until this fix, smoke resolved
// its target against GET /applications (every app the token can see, all projects,
// all environments) and wrote to the FIRST name match — so this stub lists prod's
// `core` first, which is what `cast smoke --env staging` would have written its
// canary vars onto, and (on the failure path) left them on.
//
// The wire is the witness in every test below: which uuid was written to, and —
// just as load-bearing — that the instance-wide list was never asked for at all.
type Stub = {
url: string;
hits: string[];
writes: string[];
close: () => Promise<void>;
};
const stubs: Stub[] = [];
type EnvVar = { key: string; value: string; is_buildtime: boolean };
async function stubCoolify(): Promise<Stub> {
const hits: string[] = [];
const writes: string[] = [];
// One env store per application, so a write to the wrong `core` is visible as
// a write to the wrong uuid rather than as nothing at all.
const envs: Record<string, Array<EnvVar & { uuid: string }>> = {
"a-prod-core": [],
"a-staging-core": [],
"a-client-core": [],
};
let nextUuid = 1;
const server = createServer((req, res) => {
const method = req.method ?? "GET";
const path = (req.url ?? "").replace("/api/v1", "");
hits.push(`${method} ${path}`);
if (method !== "GET") writes.push(`${method} ${path}`);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
const app = (name: string, uuid: string) => ({ name, uuid });
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/version") {
res.writeHead(200, { "content-type": "text/plain" });
return res.end("4.1.2");
}
if (path === "/projects")
return json([
{ uuid: "p-inc", name: "incubator" },
{ uuid: "p-cli", name: "client-site" },
]);
// The lookup this fix REPLACES. Answered anyway (prod's `core` first, as
// Coolify would list the older resource) so the tests can assert cast never
// asks for it — a stub that 404'd here would prove only that cast survives
// the 404.
if (path === "/applications")
return json([
app("core", "a-prod-core"),
app("core", "a-client-core"),
app("core", "a-staging-core"),
]);
if (path === "/projects/p-inc/prod")
return json({ applications: [app("core", "a-prod-core")] });
if (path === "/projects/p-inc/staging")
return json({
applications: [app("core", "a-staging-core")],
postgresqls: [app("db", "d-staging")],
});
if (path === "/projects/p-cli/staging")
return json({ applications: [app("core", "a-client-core")] });
const env = path.match(/^\/applications\/([^/]+)\/envs(\/(.+))?$/);
if (env) {
const store = envs[env[1]];
if (!store) {
res.writeHead(404);
return res.end("{}");
}
const rest = env[3];
if (method === "GET" && !rest) return json(store);
if (method === "POST" && !rest) {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const v = JSON.parse(body) as EnvVar;
const created = { ...v, uuid: `e-${nextUuid++}` };
store.push(created);
json(created);
});
}
// Upsert, mirroring verified Coolify 4.1.2 behavior — the property `smoke`
// exists to keep checking (see src/smoke.ts).
if (method === "PATCH" && rest === "bulk") {
let body = "";
req.on("data", (d) => {
body += String(d);
});
return req.on("end", () => {
const { data } = JSON.parse(body) as { data: EnvVar[] };
for (const v of data) {
const existing = store.find((e) => e.key === v.key);
if (existing) Object.assign(existing, v);
else store.push({ ...v, uuid: `e-${nextUuid++}` });
}
json({ ok: true });
});
}
if (method === "DELETE" && rest) {
envs[env[1]] = store.filter((e) => e.uuid !== rest);
res.writeHead(204);
return res.end();
}
}
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
writes,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// `targets` is the knob: what each project's binding says `smoke` should write
// to. smoke needs no manifest, no checkout, no secret store and no age key — it
// reads the state file and the live box, and nothing else.
function state(
url: string,
targets: Record<string, string> = {
"heavy-duty/incubator": "core",
"acme/client-site": "core",
},
): string {
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-"));
writeFileSync(
join(dir, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
const projects = Object.entries(targets).flatMap(([repo, target]) => [
` ${repo}:`,
` smoke_target: ${target}`,
]);
writeFileSync(
join(dir, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
" prod:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
" projects:",
...projects,
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return dir;
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "smoke", ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
describe("cast smoke — resolved inside its project + environment (#29)", () => {
it("writes to THIS environment's app, not the first `core` the instance lists", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
expect(r.output).toMatch(/smoke OK/);
// THE POINT. Every mutation landed on staging's `core` — and prod's, which
// the instance-wide lookup would have picked first, was never touched.
expect(stub.writes.length).toBeGreaterThan(0);
for (const w of stub.writes) expect(w).toContain("a-staging-core");
expect(stub.writes.join("\n")).not.toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-client-core");
// And the namespace that made prod reachable at all was never even asked
// for: the target is resolved through the project, like every other verb's.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.hits).toContain("GET /projects/p-inc/staging");
});
it("follows --env to the other environment of the same project", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
// The other half of the coordinate: same environment, same instance, same app
// name — a different project, and therefore a different application.
it("follows the repo to the other project's app of the same name", async () => {
const stub = await stubCoolify();
const r = await run([
"acme/client-site",
"--state",
state(stub.url),
"--env",
"staging",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-client-core");
expect(stub.writes.join("\n")).not.toContain("a-staging-core");
});
it("reads the box's project and environment names when they are not ours", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
// `staging` is OURS: it selects the binding and the team to assert. The
// box calls this project's environment `prod`, and only the box's name
// goes on the wire.
"--project",
"incubator",
"--environment",
"prod",
]);
expect(r.code).toBe(0);
for (const w of stub.writes) expect(w).toContain("a-prod-core");
});
});
describe("cast smoke — refusing rather than guessing (#29)", () => {
it("refuses when this project + environment holds no app of that name, and says what it does hold", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "web" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain('holds no application named "web"');
// What IS there — the finding, and the whole reason this is not a 404.
expect(r.output).toMatch(/exists here:\s+core/);
expect(r.output).toContain("smoke_target");
// Not "…so I looked on the rest of the instance and found one". An app in
// another project is a different app, and this verb writes.
expect(stub.hits).not.toContain("GET /applications");
expect(stub.writes).toEqual([]);
});
it("refuses a target that exists here but is not an application", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "heavy-duty/incubator": "db" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
// smoke POSTs to /applications/<uuid>/envs. Pointed at the postgres, it
// would 404 on an endpoint that does not exist for a database, and the
// operator would debug the status code instead of the name.
expect(r.output).toMatch(/"db" DOES exist here — as a database/);
expect(r.output).toMatch(/not an\s+application/);
expect(r.output).toContain("/envs endpoint");
expect(stub.writes).toEqual([]);
});
// The project/environment refusal, reached through the same fetchLive every
// read-side verb uses — so smoke inherits it verbatim (see renderAbsentTarget).
it("refuses an absent environment as absent, naming --environment", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url),
"--env",
"staging",
"--environment",
"production",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("refusing to smoke");
expect(r.output).toContain('has no environment "production"');
expect(stub.writes).toEqual([]);
});
it("refuses when the project declares no smoke_target at all", async () => {
const stub = await stubCoolify();
const r = await run([
"heavy-duty/incubator",
"--state",
state(stub.url, { "acme/client-site": "core" }),
"--env",
"staging",
]);
expect(r.code).toBe(2);
expect(r.output).toContain("no smoke_target for heavy-duty/incubator");
expect(r.output).toContain("smoke_target: <the application's name>");
expect(stub.writes).toEqual([]);
});
});