cast/test/smoke.test.ts

91 lines
3.5 KiB
TypeScript
Raw Permalink Normal View History

feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { describe, expect, it, vi } from "vitest";
import { CoolifyClient } from "../src/coolify.js";
import { smoke } from "../src/smoke.js";
type EnvVar = {
key: string;
value: string;
is_buildtime: boolean;
uuid: string;
};
// A stateful fetch mock standing in for Coolify's env-var store, so the
// bulk-write step's effect on previously-written keys is observable —
// `bulkMode: "upsert"` mirrors verified Coolify 4.1.2 behavior (see
// syncEnv's comment in cli.ts); `bulkMode: "replace"` simulates a
// regression to full-replace that smoke must catch.
function mockEnvStore(
appUuid: string,
bulkMode: "upsert" | "replace",
): typeof fetch {
let store: EnvVar[] = [];
let nextUuid = 1;
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const method = init?.method ?? "GET";
const path = new URL(String(url)).pathname;
const base = `/api/v1/applications/${appUuid}/envs`;
if (method === "GET" && path === base) {
return new Response(JSON.stringify(store), { status: 200 });
}
if (method === "POST" && path === base) {
const body = JSON.parse(String(init?.body)) as {
key: string;
value: string;
is_buildtime: boolean;
};
const created: EnvVar = { ...body, uuid: `env-${nextUuid++}` };
store.push(created);
return new Response(JSON.stringify(created), { status: 200 });
}
if (method === "PATCH" && path === `${base}/bulk`) {
const body = JSON.parse(String(init?.body)) as {
data: Array<{ key: string; value: string; is_buildtime: boolean }>;
};
if (bulkMode === "replace") {
store = body.data.map((v) => ({ ...v, uuid: `env-${nextUuid++}` }));
} else {
for (const v of body.data) {
const existing = store.find((e) => e.key === v.key);
if (existing) Object.assign(existing, v);
else store.push({ ...v, uuid: `env-${nextUuid++}` });
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (method === "DELETE" && path.startsWith(`${base}/`)) {
const uuid = path.slice(`${base}/`.length);
store = store.filter((e) => e.uuid !== uuid);
return new Response(null, { status: 204 });
}
if (method === "GET" && path === "/api/v1/version") {
return new Response("4.1.2", { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
}
describe("smoke", () => {
it("passes and leaves no residue when bulk env write is a true upsert", async () => {
const fetchImpl = mockEnvStore("app-1", "upsert");
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await expect(smoke(client, "app-1")).resolves.toBeUndefined();
expect(log.mock.calls.at(-1)?.[0]).toMatch(/smoke OK/);
log.mockRestore();
// both probe vars cleaned up
const envsRes = await fetchImpl(
"https://coolify.test/api/v1/applications/app-1/envs",
{ method: "GET" },
);
expect(await envsRes.json()).toEqual([]);
});
it("fails loudly when the bulk env write is destructive (full-replace regression)", async () => {
const fetchImpl = mockEnvStore("app-1", "replace");
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
await expect(smoke(client, "app-1")).rejects.toThrow(
/bulk env write is destructive \(full-replace\) — never-delete broken/,
);
});
});