cast/test/github-app-register-cli.test.ts
dan-claude-bot a9805d31bd feat: cast github-app create/register — run the App Manifest flow instead of transcribing it
The GitHub App was the one piece of a Coolify instance cast could not
reproduce. There is no REST endpoint that creates one — no POST /apps, no
GraphQL mutation, no `gh app` subcommand, no PAT scope — so `create` runs the
only programmatic path there is: GitHub's App Manifest flow, a one-shot page
served on 127.0.0.1 whose form POST the operator's own browser session
authenticates, followed by an unauthenticated code exchange.

That exchange is the only moment GitHub yields the private key, the client
secret and the webhook secret together; all three are persisted to
<state>/github-apps/ at 0600 under a .gitignore of `*`.

`create` does not reimplement `register`: it obtains credentials and then calls
exactly that path. Both verbs end at GET /github-apps/{id}/repositories,
asserting the repo is actually reachable — the check that turns a silent
misconfiguration into an error next to the thing that caused it.

github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent
entry, and is refused when it disagrees), the client secret is stdin-only, and
--webhook-secret is optional. scripts/register-github-app.sh is deleted.

No new dependencies: node:http for the callback, node:crypto's
createSign("RSA-SHA256") for the App JWT that recovers the installation id from
the App's own key rather than from a spoofable redirect parameter.

Closes #7

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

280 lines
9.1 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" });
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");
});
});