cast/test/github-app-register-cli.test.ts
dan-claude-bot 5a1ec74e04 fix: persist the manifest conversion before the install poll can lose it
All three reviewers, independently: `createGithubApp` held the one-shot
conversion payload in memory across `awaitInstallationId` — a ~5 minute
poll — and `persistCredentials` ran only inside `registerGithubApp`. A
timeout, a dropped network or a Ctrl-C during that wait destroyed a
private key and client secret GitHub never re-shows, and left the App
orphaned on GitHub. The timeout message then claimed the credentials
were "already there" under `<state>/github-apps/`, which was false on
exactly the path that printed it.

The payload now goes to disk the instant the exchange returns, complete
but for the installation id — the one field GitHub will answer again as
often as it is asked. It is written as `installation_id: null` and
backfilled on success; `writeCredentialsRecord` allows precisely that
one transition and refuses every other difference, so nothing
irreplaceable is ever overwritten silently. The timeout path now names
the two files it wrote and prints the `register` command that finishes
the job, and says not to re-run `create`.

claude-bot's addition: persisting post-conversion could still throw in
`writeExclusive` against a stale `<name>.pem`, losing the fresh key just
the same — and that refusal's remedy ("pass --force and re-run") would
mean minting a second App. So the collision is pre-flighted before the
browser flow starts, when nothing exists and nothing can be lost. The
post-conversion persist now only ever meets a clean slot or an exact
match, and `writeExclusive`'s wording stays honest for `register`.

grok #2: re-running `register` to re-check a failed repo-visibility
assertion used to re-POST the key and the App first. Coolify does not
de-dupe by name — `GithubController@create` validates
`'name' => 'required|string|max:255'` with no `unique` rule and calls a
plain `GithubApp::create()`, and the vendored OpenAPI documents no
conflict response — so following that advice created a second Source
every time. Both verbs now read `GET /github-apps` first and verify an
existing record of that name instead of creating another; a name held by
a different App, or already duplicated, is a hard error. An unreadable
list warns and proceeds rather than blocking a bootstrap command.

grok #3: `name` becomes `<name>.pem`/`<name>.json`, so separators, dot
references, empties and control characters are rejected where the name
is resolved and again where it becomes a filename.

grok #4: every GitHub request now sends `User-Agent: cast/<version>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:11:15 +00:00

284 lines
9.4 KiB
TypeScript

import { spawn } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { mkdtempSync, readFileSync, 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, beforeAll, describe, expect, it } from "vitest";
// `cast github-app register` through the real CLI: argv parsing, the stdin-only
// client secret, the team assert, the name resolved from state, the
// post-condition check, and WHEN environments.yaml is written.
//
// The Coolify here is a stub. Registration against a live instance is
// operator-only territory (#7's testability boundary) and nothing in this file
// pretends otherwise — what it proves is that cast sends the right things and
// reacts correctly to each answer.
let privateKeyPem: string;
beforeAll(() => {
privateKeyPem = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
});
type Stub = {
url: string;
hits: string[];
bodies: Record<string, Record<string, unknown>>;
close: () => Promise<void>;
};
const stubs: Stub[] = [];
async function stubCoolify(opts: { repositories: unknown }): Promise<Stub> {
const hits: string[] = [];
const bodies: Record<string, Record<string, unknown>> = {};
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
const key = `${req.method} ${path}`;
hits.push(key);
let raw = "";
req.on("data", (d) => {
raw += String(d);
});
req.on("end", () => {
if (raw) bodies[key] = JSON.parse(raw);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/security/keys") return json({ uuid: "key-uuid-1" });
if (path === "/github-apps" && req.method === "POST")
return json({ id: 7, uuid: "app-uuid" });
// A clean instance: nothing registered under this name yet, so register
// goes on to create. (The list read is how it avoids a duplicate Source
// on a re-run — Coolify does not enforce unique names.)
if (path === "/github-apps" && req.method === "GET") return json([]);
if (path === "/github-apps/7/repositories")
return json({ repositories: opts.repositories });
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,
bodies,
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()));
});
function fixture(
url: string,
githubApps: string,
): { state: string; pem: string } {
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
writeFileSync(
join(state, "environments.yaml"),
[
"# hand-maintained",
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
githubApps,
"",
].join("\n"),
);
const pem = join(state, "downloaded.pem");
writeFileSync(pem, privateKeyPem);
return { state, pem };
}
function run(
args: string[],
stdin: string | null,
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", ...args], {
stdio: [stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
});
if (stdin !== null) {
child.stdin?.end(stdin);
}
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 }));
});
}
const REGISTER = (state: string, pem: string) => [
"github-app",
"register",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
state,
"--app-id",
"12345",
"--installation-id",
"99887766",
"--client-id",
"Iv23liABCDEF",
"--client-secret-stdin",
"--private-key",
pem,
];
describe("cast github-app register", () => {
it("registers against the name in state, verifies the repo, and never takes the secret from argv", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(REGISTER(f.state, f.pem), "the-client-secret\n");
expect(r.code).toBe(0);
expect(r.output).toContain('team id=0 name="Root Team" ✓');
expect(r.output).toContain("(from environments.yaml)");
expect(r.output).toContain(
"verified: hdb-coolify-prod can clone heavy-duty/incubator ✓",
);
// The secret reached Coolify, and it came off stdin — it is nowhere in
// argv, which `ps` shows and shell history keeps.
expect(stub.bodies["POST /github-apps"].client_secret).toBe(
"the-client-secret",
);
expect(stub.bodies["POST /security/keys"].name).toBe(
"hdb-coolify-prod-key",
);
// A webhook-INACTIVE App is the right shape for a tailnet-only Coolify, so
// no operator has to invent a placeholder any more (#5 footgun 3).
expect(r.output).toContain("generated one");
expect(
String(stub.bodies["POST /github-apps"].webhook_secret).length,
).toBeGreaterThan(0);
// The credentials landed in the state dir, under a git-ignored directory.
expect(
readFileSync(
join(f.state, "github-apps", "hdb-coolify-prod.pem"),
"utf8",
),
).toBe(privateKeyPem);
expect(
readFileSync(join(f.state, "github-apps", ".gitignore"), "utf8"),
).toContain("*");
});
it("seeds an ABSENT binding from --name, keyed by the full slug, comments intact", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(0);
const after = readFileSync(join(f.state, "environments.yaml"), "utf8");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
expect(after).toContain("# hand-maintained");
});
it("REFUSES a --name that disagrees with the state file", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "My Cool App"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("disagrees with environments.yaml");
// Refused before it touched Coolify at all — not even the team assert.
expect(stub.hits).toEqual([]);
});
it("refuses a client secret passed any way other than stdin", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const withoutFlag = REGISTER(f.state, f.pem).filter(
(a) => a !== "--client-secret-stdin",
);
const r = await run(withoutFlag, null);
expect(r.code).toBe(2);
expect(r.output).toContain("--client-secret-stdin is required");
});
it("fails, and does NOT seed state, when the App cannot see the repo", async () => {
// A state file naming an App that does not work is worse than one naming
// none: the next `cast apply` resolves it, uses it, and fails at clone time.
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/something-else" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("cannot see heavy-duty/incubator");
expect(r.output).toContain("can see: heavy-duty/something-else");
expect(readFileSync(join(f.state, "environments.yaml"), "utf8")).toContain(
"github_apps: {}",
);
});
it("refuses a read-only instance before any write", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
writeFileSync(
join(f.state, ".coolify.env"),
`COOLIFY_BASE_URL="${stub.url}"\nCOOLIFY_ACCESS_TOKEN="t"\nCOOLIFY_READ_ONLY=true\n`,
);
const r = await run(REGISTER(f.state, f.pem), "s\n");
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to github-app register");
expect(stub.hits).toEqual([]);
});
it("prints usage for an unknown subcommand", async () => {
const r = await run(["github-app", "wat"], null);
expect(r.code).toBe(2);
expect(r.output).toContain("cast github-app create");
expect(r.output).toContain("cast github-app register");
});
});