cast/test/github-app.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

1322 lines
46 KiB
TypeScript

import { createVerify, generateKeyPairSync } from "node:crypto";
import {
existsSync,
mkdtempSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { loadBindings } from "../src/bindings.js";
import { CoolifyClient } from "../src/coolify.js";
import {
type AppCredentials,
type PendingAppCredentials,
awaitInstallationId,
buildManifest,
convertManifestCode,
createGithubApp,
detectOwnerType,
findInstallationId,
findRegisteredApp,
githubUserAgent,
manifestFormPage,
mintAppJwt,
newAppFormAction,
persistCredentials,
preflightCredentialSlot,
preflightOrgAdmin,
readAppRepositories,
registerGithubApp,
resolveAppName,
seedGithubAppBinding,
startManifestServer,
} from "../src/github-app.js";
// WHAT THIS FILE DOES NOT TEST, said out loud because the issue asks for it
// (#7, "Testability boundary"):
//
// - The browser form POST. It is authenticated by the operator's logged-in
// GitHub session and there is no headless path to it. Nothing here proves
// that GitHub accepts a `redirect_url` on http://127.0.0.1:<port> — that
// assumption is the load-bearing one, it is unvalidated, and the first real
// run is an operator's.
// - Registration against a live Coolify. Every Coolify call below is mocked.
//
// What IS proven here is everything on cast's side of that line: the JSON it
// builds, the server it serves, the JWT it signs, the requests it makes, and
// what it does with each answer.
let privateKeyPem: string;
let publicKeyPem: string;
beforeAll(() => {
const pair = generateKeyPairSync("rsa", { modulusLength: 2048 });
privateKeyPem = pair.privateKey.export({
type: "pkcs8",
format: "pem",
}) as string;
publicKeyPem = pair.publicKey.export({
type: "spki",
format: "pem",
}) as string;
});
function tmp(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
function creds(over: Partial<AppCredentials> = {}): AppCredentials {
return {
appId: 12345,
installationId: 99887766,
clientId: "Iv23liABCDEF",
clientSecret: "cs-secret",
webhookSecret: "wh-secret",
privateKeyPem: privateKeyPem ?? "PEM",
...over,
};
}
// A Coolify whose every route is declared by the test, and which records what
// it was asked — so a test can assert on the ABSENCE of a call as easily as on
// its presence.
function coolify(
routes: Record<string, (body: unknown) => [number, unknown]>,
): { client: CoolifyClient; hits: string[]; bodies: Record<string, unknown> } {
const hits: string[] = [];
const bodies: Record<string, unknown> = {};
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname.replace("/api/v1", "");
const key = `${init?.method ?? "GET"} ${path}`;
hits.push(key);
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
if (body !== undefined) bodies[key] = body;
const route = routes[key];
if (!route) return new Response("no such route", { status: 404 });
const [status, payload] = route(body);
return new Response(JSON.stringify(payload), { status });
}) as unknown as typeof fetch;
return {
client: new CoolifyClient("https://coolify.test", "tok", fetchImpl),
hits,
bodies,
};
}
describe("the manifest cast POSTs to GitHub", () => {
const manifest = buildManifest({
name: "hdb-coolify-prod",
orgRepo: "heavy-duty/incubator",
redirectUrl: "http://127.0.0.1:8765/callback",
});
it("declares clone-only permissions in snake_case", () => {
// Hyphenated keys (`pull-requests`, as the docs' reference page renders
// them) are silently wrong and cost an App you have to delete.
expect(manifest.default_permissions).toEqual({
contents: "read",
metadata: "read",
});
for (const key of Object.keys(
manifest.default_permissions as Record<string, string>,
)) {
expect(key).not.toContain("-");
}
});
it("subscribes to no events and keeps the webhook inactive on a dead url", () => {
expect(manifest.default_events).toEqual([]);
expect(manifest.hook_attributes).toEqual({
// Required by the schema even when inactive, so it points at a name that
// can never resolve (RFC 2606 reserves `.invalid`).
url: "https://example.invalid/unused",
active: false,
});
});
it("is private, points at the repo, and redirects to the loopback LITERAL", () => {
expect(manifest.public).toBe(false);
expect(manifest.url).toBe("https://github.com/heavy-duty/incubator");
expect(manifest.redirect_url).toBe("http://127.0.0.1:8765/callback");
// Never `localhost`: it resolves through the host's name resolution, which
// other software on the machine can change.
expect(String(manifest.redirect_url)).not.toContain("localhost");
});
it("targets the org form for an org and the personal form for a user", () => {
expect(newAppFormAction("heavy-duty", "Organization")).toBe(
"https://github.com/organizations/heavy-duty/settings/apps/new",
);
expect(newAppFormAction("danmt", "User")).toBe(
"https://github.com/settings/apps/new",
);
});
it("escapes the manifest into the form field rather than breaking out of it", () => {
const page = manifestFormPage({
manifest: { name: 'a"><script>x</script>' },
formAction: "https://github.com/settings/apps/new",
csrf: "tok/en",
appName: "x",
});
expect(page).not.toContain("<script>x</script>");
expect(page).toContain("&quot;");
// The csrf token rides the action as `state`, url-encoded.
expect(page).toContain("state=tok%2Fen");
});
});
describe("the loopback callback server", () => {
it("serves the auto-submitting form and captures the code from a real request", async () => {
// Driven with a real HTTP request against a real ephemeral server — the
// behaviour under test is an HTTP handshake, so nothing here is stubbed.
const server = await startManifestServer({
csrf: "csrf-value",
port: 0,
appName: "hdb-coolify-prod",
formAction:
"https://github.com/organizations/heavy-duty/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({
name: "hdb-coolify-prod",
orgRepo: "heavy-duty/incubator",
redirectUrl,
}),
});
try {
// The manifest could not have been built before listen(): with port 0 the
// port is only known afterwards, and it is inside redirect_url.
expect(server.manifest.redirect_url).toBe(
`http://127.0.0.1:${server.port}/callback`,
);
const page = await (await fetch(server.startUrl)).text();
expect(page).toContain('name="manifest"');
expect(page).toContain("hdb-coolify-prod");
expect(page).toContain("state=csrf-value");
const res = await fetch(
`${server.startUrl}callback?code=abc123&state=csrf-value`,
);
expect(res.status).toBe(200);
expect(await server.code).toBe("abc123");
} finally {
await server.close();
}
});
it("refuses a callback carrying the wrong state, and keeps serving the right one", async () => {
const server = await startManifestServer({
csrf: "the-real-token",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
try {
const forged = await fetch(
`${server.startUrl}callback?code=attacker&state=guessed`,
);
expect(forged.status).toBe(400);
expect(await forged.text()).toContain("state mismatch");
// The load-bearing half: refusing a forgery must not also cancel the
// real callback the operator is still on their way to producing.
const real = await fetch(
`${server.startUrl}callback?code=genuine&state=the-real-token`,
);
expect(real.status).toBe(200);
expect(await server.code).toBe("genuine");
} finally {
await server.close();
}
});
it("400s a callback with no code at all", async () => {
const server = await startManifestServer({
csrf: "t",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
try {
const res = await fetch(`${server.startUrl}callback?state=t`);
expect(res.status).toBe(400);
} finally {
await server.close();
}
});
it("rejects the pending code when it is closed without a callback", async () => {
const server = await startManifestServer({
csrf: "t",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
const pending = server.code;
await server.close();
await expect(pending).rejects.toThrow(/callback never arrived/);
});
});
describe("the App JWT", () => {
// Verified independently: this test does not call cast's own code to check
// cast's signature. It re-derives the segments and verifies with the PUBLIC
// key, which is what GitHub does.
function decode(jwt: string) {
const [h, p, s] = jwt.split(".");
return {
header: JSON.parse(Buffer.from(h, "base64url").toString("utf8")),
payload: JSON.parse(Buffer.from(p, "base64url").toString("utf8")),
signingInput: `${h}.${p}`,
signature: Buffer.from(s, "base64url"),
};
}
it("is an RS256 signature over the two segments, verifiable with the public key", () => {
const jwt = mintAppJwt({ privateKeyPem, clientId: "Iv23liABCDEF" });
const { header, signingInput, signature } = decode(jwt);
expect(header).toEqual({ alg: "RS256", typ: "JWT" });
expect(
createVerify("RSA-SHA256")
.update(signingInput)
.verify(publicKeyPem, signature),
).toBe(true);
});
it("does not verify against a different key", () => {
const other = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwt = mintAppJwt({ privateKeyPem, clientId: "x" });
const { signingInput, signature } = decode(jwt);
expect(
createVerify("RSA-SHA256")
.update(signingInput)
.verify(
other.publicKey.export({ type: "spki", format: "pem" }) as string,
signature,
),
).toBe(false);
});
it("backdates iat, stays inside GitHub's 10-minute ceiling, and issues as the CLIENT id", () => {
const now = 1_770_000_000_000;
const nowSeconds = Math.floor(now / 1000);
const { payload } = decode(
mintAppJwt({ privateKeyPem, clientId: "Iv23liABCDEF", now }),
);
// Backdated against clock skew — GitHub's own documented advice.
expect(payload.iat).toBe(nowSeconds - 60);
expect(payload.iat).toBeLessThan(nowSeconds);
// "no more than 10 minutes into the future", measured from iat. Cast sits
// inside the ceiling rather than on it: a JWT rejected for being one second
// too long looks exactly like a bad key from the operator's side.
expect(payload.exp - payload.iat).toBeLessThanOrEqual(600);
expect(payload.exp).toBeGreaterThan(nowSeconds);
// `iss` is the client id, which GitHub now recommends over the app id.
expect(payload.iss).toBe("Iv23liABCDEF");
});
});
describe("the manifest code exchange", () => {
const conversionBody = {
id: 424242,
slug: "hdb-coolify-prod",
client_id: "Iv23liABCDEF",
client_secret: "cs",
webhook_secret: "wh",
pem: "-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----\n",
owner: { login: "heavy-duty", type: "Organization" },
};
it("POSTs to the conversions endpoint with NO Authorization header", async () => {
const fetchImpl = vi.fn(
async () => new Response(JSON.stringify(conversionBody), { status: 200 }),
) as unknown as typeof fetch;
const out = await convertManifestCode("the-code", fetchImpl);
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe(
"https://api.github.com/app-manifests/the-code/conversions",
);
expect(init.method).toBe("POST");
// The code IS the credential. Sending a token here is not merely
// unnecessary — the endpoint is documented as unauthenticated.
expect(Object.keys(init.headers)).not.toContain("Authorization");
expect(out.clientSecret).toBe("cs");
expect(out.ownerLogin).toBe("heavy-duty");
expect(out.ownerType).toBe("Organization");
});
it("turns a 404 into the remedy, because the code is spent and retrying the exchange cannot help", async () => {
const fetchImpl = vi.fn(
async () => new Response("Not Found", { status: 404 }),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/valid for one hour[\s\S]*run `cast github-app create` again/,
);
});
it("turns a 422 into the rate-limit remedy rather than 'bad manifest'", async () => {
const fetchImpl = vi.fn(
async () => new Response("Unprocessable", { status: 422 }),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/rate-limit[\s\S]*wait a few minutes/,
);
});
it("refuses a partial body instead of persisting half a credential", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ ...conversionBody, pem: undefined }), {
status: 200,
}),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/missing fields cast needs/,
);
});
it("tolerates an absent webhook secret by reporting it as absent, not empty", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ ...conversionBody, webhook_secret: null }),
{ status: 200 },
),
) as unknown as typeof fetch;
expect(
(await convertManifestCode("c", fetchImpl)).webhookSecret,
).toBeNull();
});
});
describe("recovering the installation id from the App's own key", () => {
function githubFetch(
handler: (path: string, init?: RequestInit) => Response,
): { impl: typeof fetch; paths: string[] } {
const paths: string[] = [];
const impl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
paths.push(path);
return handler(path, init);
}) as unknown as typeof fetch;
return { impl, paths };
}
it("asks the org endpoint with the JWT as a bearer token", async () => {
const { impl, paths } = githubFetch(
() => new Response(JSON.stringify({ id: 5150 }), { status: 200 }),
);
const id = await findInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
jwt: "the.jwt.here",
fetchImpl: impl,
});
expect(id).toBe(5150);
expect(paths[0]).toBe("/orgs/heavy-duty/installation");
const [, init] = (impl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(init.headers.Authorization).toBe("Bearer the.jwt.here");
});
it("asks the user endpoint for a personal account", async () => {
const { impl, paths } = githubFetch(
() => new Response(JSON.stringify({ id: 1 }), { status: 200 }),
);
await findInstallationId({
owner: "danmt",
ownerType: "User",
jwt: "j",
fetchImpl: impl,
});
expect(paths[0]).toBe("/users/danmt/installation");
});
it("reads a 404 as 'not installed yet' and a 500 as an error — they are different facts", async () => {
const notInstalled = githubFetch(() => new Response("", { status: 404 }));
expect(
await findInstallationId({
owner: "o",
ownerType: "Organization",
jwt: "j",
fetchImpl: notInstalled.impl,
}),
).toBeUndefined();
const broken = githubFetch(() => new Response("boom", { status: 500 }));
await expect(
findInstallationId({
owner: "o",
ownerType: "Organization",
jwt: "j",
fetchImpl: broken.impl,
}),
).rejects.toThrow(/→ 500/);
});
it("polls while the operator clicks through the install screen", async () => {
let call = 0;
const impl = vi.fn(async () => {
call++;
return call < 3
? new Response("", { status: 404 })
: new Response(JSON.stringify({ id: 777 }), { status: 200 });
}) as unknown as typeof fetch;
const slept: number[] = [];
const id = await awaitInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
privateKeyPem,
clientId: "Iv1",
fetchImpl: impl,
intervalMs: 5000,
sleep: async (ms) => {
slept.push(ms);
},
});
expect(id).toBe(777);
expect(call).toBe(3);
expect(slept).toEqual([5000, 5000]);
});
it("gives up with an error that says the App exists and only the install is missing", async () => {
const impl = vi.fn(
async () => new Response("", { status: 404 }),
) as unknown as typeof fetch;
await expect(
awaitInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
privateKeyPem,
clientId: "Iv1",
fetchImpl: impl,
attempts: 2,
intervalMs: 1,
sleep: async () => {},
}),
).rejects.toThrow(
/never installed on heavy-duty[\s\S]*The App exists on GitHub/,
);
});
});
describe("the Coolify-facing name is resolved from state, not from a flag (#5 footgun 1)", () => {
const bindings = (apps: Record<string, string>) =>
loadBindings("", {
overrideText: [
"environments:",
" prod:",
" server: box",
" team: { id: 0, name: Root Team }",
`github_apps: ${JSON.stringify(apps)}`,
"",
].join("\n"),
});
it("uses the full-slug entry and ignores a matching --name", () => {
const b = bindings({ "heavy-duty/incubator": "hdb-coolify-prod" });
expect(
resolveAppName({ bindings: b, orgRepo: "heavy-duty/incubator" }),
).toEqual({ name: "hdb-coolify-prod", seed: false });
expect(
resolveAppName({
bindings: b,
orgRepo: "heavy-duty/incubator",
nameFlag: "hdb-coolify-prod",
}).name,
).toBe("hdb-coolify-prod");
});
it("still honours a legacy bare-repo key (#6's compatibility fallback)", () => {
expect(
resolveAppName({
bindings: bindings({ incubator: "legacy-name" }),
orgRepo: "heavy-duty/incubator",
}),
).toEqual({ name: "legacy-name", seed: false });
});
it("REFUSES a --name that disagrees with state — the footgun, dissolved", () => {
expect(() =>
resolveAppName({
bindings: bindings({ "heavy-duty/incubator": "hdb-coolify-prod" }),
orgRepo: "heavy-duty/incubator",
nameFlag: "My Cool App",
}),
).toThrow(
/disagrees with environments.yaml[\s\S]*state file is the authority/,
);
});
it("seeds from --name only when the entry is absent, and refuses when neither exists", () => {
expect(
resolveAppName({
bindings: bindings({}),
orgRepo: "heavy-duty/incubator",
nameFlag: "hdb-coolify-prod",
}),
).toEqual({ name: "hdb-coolify-prod", seed: true });
expect(() =>
resolveAppName({
bindings: bindings({}),
orgRepo: "heavy-duty/incubator",
}),
).toThrow(/no GitHub App name for heavy-duty\/incubator/);
});
it("writes the seeded entry by FULL slug, preserving the operator's comments", () => {
const dir = tmp("cast-bind-");
const path = join(dir, "environments.yaml");
const original = [
"# the control plane's bindings — hand maintained",
"environments:",
" prod:",
" server: box # the tailnet one",
" team: { id: 0, name: Root Team }",
"",
"github_apps: {}",
"",
].join("\n");
writeFileSync(path, original);
seedGithubAppBinding(path, "heavy-duty/incubator", "hdb-coolify-prod");
const after = readFileSync(path, "utf8");
expect(after).toContain("# the control plane's bindings — hand maintained");
expect(after).toContain("# the tailnet one");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
// And it round-trips through the real schema.
expect(loadBindings(path).github_apps["heavy-duty/incubator"]).toBe(
"hdb-coolify-prod",
);
});
});
describe("where the secrets land", () => {
it("writes the PEM and the other two secrets 0600, under a directory git ignores by default", () => {
const state = tmp("cast-state-");
const { pemPath, secretsPath } = persistCredentials({
stateDir: state,
name: "hdb-coolify-prod",
creds: creds(),
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
});
expect(readFileSync(pemPath, "utf8")).toBe(creds().privateKeyPem);
const saved = JSON.parse(readFileSync(secretsPath, "utf8"));
// All three, because the conversion response is the ONLY time GitHub yields
// them and `register` needs the client secret to be re-runnable at all.
expect(saved.client_secret).toBe("cs-secret");
expect(saved.webhook_secret).toBe("wh-secret");
expect(saved.app_id).toBe(12345);
expect(saved.installation_id).toBe(99887766);
for (const p of [pemPath, secretsPath]) {
expect(statSync(p).mode & 0o777).toBe(0o600);
}
// The structural half of the "loud note": `git add -A` in the state repo
// cannot commit plaintext credentials by accident.
const ignore = join(state, "github-apps", ".gitignore");
expect(existsSync(ignore)).toBe(true);
expect(readFileSync(ignore, "utf8")).toContain("*");
});
it("is idempotent on identical content and REFUSES to overwrite different content", () => {
const state = tmp("cast-state-");
const args = {
stateDir: state,
name: "app",
creds: creds(),
org: "o",
orgRepo: "o/r",
};
persistCredentials(args);
expect(() => persistCredentials(args)).not.toThrow();
expect(() =>
persistCredentials({
...args,
creds: creds({ privateKeyPem: "a different key" }),
}),
).toThrow(/refusing to overwrite[\s\S]*only copy in existence/);
// --force is the deliberate escape hatch for a stale half-run.
expect(() =>
persistCredentials({
...args,
creds: creds({ privateKeyPem: "a different key" }),
force: true,
}),
).not.toThrow();
});
});
describe("registering with Coolify, and the post-condition that matters", () => {
const ok = (payload: unknown) => () => [200, payload] as [number, unknown];
it("uploads the key, creates the App, and PROVES it can reach the repo", async () => {
const c = coolify({
"GET /github-apps": ok([{ id: 4, name: "something-else" }]),
"POST /security/keys": ok({ uuid: "key-uuid-1" }),
"POST /github-apps": ok({ id: 7, uuid: "app-uuid" }),
"GET /github-apps/7/repositories": ok({
repositories: [
{ full_name: "heavy-duty/other" },
{ full_name: "heavy-duty/incubator" },
],
}),
});
const out = await registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
});
expect(c.hits).toEqual([
"GET /github-apps",
"POST /security/keys",
"POST /github-apps",
"GET /github-apps/7/repositories",
]);
expect(c.bodies["POST /security/keys"]).toEqual({
name: "hdb-coolify-prod-key",
private_key: creds().privateKeyPem,
});
expect(c.bodies["POST /github-apps"]).toEqual({
name: "hdb-coolify-prod",
organization: "heavy-duty",
api_url: "https://api.github.com",
html_url: "https://github.com",
app_id: 12345,
installation_id: 99887766,
client_id: "Iv23liABCDEF",
client_secret: "cs-secret",
// No invented placeholder any more (#5 footgun 3).
webhook_secret: "wh-secret",
private_key_uuid: "key-uuid-1",
});
expect(out.coolifyAppId).toBe(7);
expect(out.repositories).toContain("heavy-duty/incubator");
});
it("fails HARD when the App cannot see the repo, naming what it can see", async () => {
// The whole point of step 9. Without it this misconfiguration surfaces
// hours later, in a different command, as an unresolvable source.
const c = coolify({
"POST /security/keys": ok({ uuid: "k" }),
"POST /github-apps": ok({ id: 9 }),
"GET /github-apps/9/repositories": ok({
repositories: [{ full_name: "heavy-duty/something-else" }],
}),
});
await expect(
registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(
/cannot see heavy-duty\/incubator[\s\S]*can see: heavy-duty\/something-else/,
);
});
it("fails when the repo list is UNREADABLE, rather than reporting it as empty", async () => {
const c = coolify({
"POST /security/keys": ok({ uuid: "k" }),
"POST /github-apps": ok({ id: 9 }),
"GET /github-apps/9/repositories": ok({ repositories: "not a list" }),
});
await expect(
registerGithubApp({
client: c.client,
name: "n",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(/cannot verify that n can reach o\/r/);
});
it("persists the credentials BEFORE the Coolify calls, so a Coolify failure does not lose the key", async () => {
const c = coolify({}); // every route 404s
const state = tmp("cast-state-");
await expect(
registerGithubApp({
client: c.client,
name: "app",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: state,
log: () => {},
}),
).rejects.toThrow();
// GitHub shows the private key once. Losing it to a failed HTTP call would
// mean deleting the App and starting over.
expect(existsSync(join(state, "github-apps", "app.pem"))).toBe(true);
});
it("reads a bare array and an owner/name pair as well as full_name", async () => {
const c = coolify({
"GET /github-apps/3/repositories": ok([
{ owner: { login: "heavy-duty" }, name: "incubator" },
]),
});
expect(await readAppRepositories(c.client, 3)).toEqual([
"heavy-duty/incubator",
]);
});
it("treats ONE unreadable row as an unreadable list — a partial list reads exactly like a complete one", async () => {
const c = coolify({
"GET /github-apps/3/repositories": ok({
repositories: [{ full_name: "a/b" }, { nothing: "usable" }],
}),
});
expect(await readAppRepositories(c.client, 3)).toBeUndefined();
});
});
// grok #2, and the reason it is a real bug rather than a hypothetical: Coolify
// does not enforce unique Source names. Its GithubController@create validates
// `'name' => 'required|string|max:255'` — no `unique` — then calls a plain
// `GithubApp::create()`. So the old "re-run register to re-check" advice
// created a second Source every time it was followed.
describe("re-running `register` re-verifies instead of registering twice", () => {
const ok = (payload: unknown) => () => [200, payload] as [number, unknown];
it("reuses an existing Source of the same name and POSTs NOTHING", async () => {
const c = coolify({
"GET /github-apps": ok([
{ id: 4, name: "other-app", app_id: 1 },
{ id: 7, name: "hdb-coolify-prod", app_id: 12345 },
]),
"GET /github-apps/7/repositories": ok([
{ full_name: "heavy-duty/incubator" },
]),
});
const out = await registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
});
// The verify-only path: the list, then the check. No key upload, no App
// create — following the error message's own advice is now free.
expect(c.hits).toEqual([
"GET /github-apps",
"GET /github-apps/7/repositories",
]);
expect(out.coolifyAppId).toBe(7);
expect(out.keyUuid).toBeNull();
});
it("refuses when the name is taken by a DIFFERENT App rather than shadowing it", async () => {
const c = coolify({
"GET /github-apps": ok([
{ id: 7, name: "hdb-coolify-prod", app_id: 999999 },
]),
});
await expect(
registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(/DIFFERENT App[\s\S]*github app id 999999/);
expect(c.hits).toEqual(["GET /github-apps"]);
});
it("refuses when duplicates ALREADY exist, because cast cannot pick one", async () => {
const c = coolify({
"GET /github-apps": ok([
{ id: 7, name: "dup", app_id: 12345 },
{ id: 8, name: "dup", app_id: 12345 },
]),
});
await expect(
registerGithubApp({
client: c.client,
name: "dup",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(/2 GitHub App records named dup[\s\S]*coolify ids: 7, 8/);
});
it("warns and proceeds when the list is unreadable — a bootstrap must not be blocked by a check", async () => {
const lines: string[] = [];
const c = coolify({
// No "GET /github-apps" route: the list 404s.
"POST /security/keys": ok({ uuid: "k" }),
"POST /github-apps": ok({ id: 3 }),
"GET /github-apps/3/repositories": ok([{ full_name: "o/r" }]),
});
const out = await registerGithubApp({
client: c.client,
name: "n",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: tmp("cast-state-"),
log: (l) => lines.push(l),
});
expect(out.coolifyAppId).toBe(3);
// Unreadable is not "empty" — it is said out loud, not assumed away.
expect(lines.join("\n")).toContain("could not list existing Coolify");
});
it("reads names off the list and ignores everything else", async () => {
const c = coolify({
"GET /github-apps": ok([
{ id: 1, name: "a" },
"not an object",
{ name: "wanted-but-no-id" },
{ id: 2, name: "wanted", app_id: 5 },
]),
});
expect(await findRegisteredApp(c.client, "wanted")).toEqual([
{ id: 2, appId: 5 },
]);
expect(await findRegisteredApp(c.client, "absent")).toEqual([]);
});
});
// grok #3. Not a security boundary — the name is the operator's own — but a
// slash in it silently nests the credentials somewhere nobody will look, and
// `..` walks clean out of the state directory.
describe("the App name has to be usable as a filename", () => {
it("rejects separators, dot-references and empties before they become paths", () => {
for (const bad of ["", "a/b", "a\\b", "..", ".", "../escape", ".hidden"]) {
expect(() =>
persistCredentials({
stateDir: tmp("cast-state-"),
name: bad,
creds: creds(),
org: "o",
orgRepo: "o/r",
}),
).toThrow(/github app name/);
}
expect(() =>
preflightCredentialSlot({ stateDir: tmp("cast-state-"), name: "a/b" }),
).toThrow(/contains a path separator/);
// Ordinary names stay ordinary.
expect(() =>
persistCredentials({
stateDir: tmp("cast-state-"),
name: "hdb-coolify-prod",
creds: creds(),
org: "o",
orgRepo: "o/r",
}),
).not.toThrow();
});
it("catches it at name resolution too, so a bad --name never reaches a network", () => {
const empty = loadBindings("", {
overrideText: [
"environments:",
" prod:",
" server: box",
" team: { id: 0, name: Root Team }",
"github_apps: {}",
"",
].join("\n"),
});
expect(() =>
resolveAppName({
bindings: empty,
orgRepo: "heavy-duty/incubator",
nameFlag: "../oops",
}),
).toThrow(/github app name/);
// And a hand-edited environments.yaml entry gets the same treatment: it
// becomes a filename by exactly the same route.
const bad = loadBindings("", {
overrideText: [
"environments:",
" prod:",
" server: box",
" team: { id: 0, name: Root Team }",
'github_apps: { "heavy-duty/incubator": "../oops" }',
"",
].join("\n"),
});
expect(() =>
resolveAppName({ bindings: bad, orgRepo: "heavy-duty/incubator" }),
).toThrow(/github app name/);
});
});
// grok #4.
describe("cast identifies itself to GitHub", () => {
it("sends a User-Agent, because GitHub asks for one and 403s look like nothing else", async () => {
expect(githubUserAgent()).toMatch(/^cast\//);
const seen: Record<string, string>[] = [];
const fetchImpl = vi.fn(async (_url: string | URL, init?: RequestInit) => {
seen.push((init?.headers ?? {}) as Record<string, string>);
return new Response(JSON.stringify({ type: "Organization" }), {
status: 200,
});
}) as unknown as typeof fetch;
await detectOwnerType("heavy-duty", fetchImpl);
await findInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
jwt: "jwt",
fetchImpl,
}).catch(() => {});
await convertManifestCode("code", fetchImpl).catch(() => {});
expect(seen.length).toBe(3);
for (const headers of seen) {
expect(headers["User-Agent"]).toBe(githubUserAgent());
}
});
});
describe("the optional org-admin preflight", () => {
it("passes on admin and refuses on anything else", () => {
expect(preflightOrgAdmin("heavy-duty", () => '{"role":"admin"}')).toEqual({
kind: "admin",
});
expect(preflightOrgAdmin("heavy-duty", () => '{"role":"member"}')).toEqual({
kind: "not-admin",
role: "member",
});
});
it("skips silently when gh is absent — a nice-to-have must never become a dependency", () => {
const result = preflightOrgAdmin("heavy-duty", () => {
throw new Error("ENOENT");
});
expect(result.kind).toBe("skipped");
});
});
describe("detecting whether the owner is an org or a personal account", () => {
it("reads the type, and falls back to undefined rather than guessing on failure", async () => {
const okFetch = vi.fn(
async () =>
new Response(JSON.stringify({ type: "User" }), { status: 200 }),
) as unknown as typeof fetch;
expect(await detectOwnerType("danmt", okFetch)).toBe("User");
const badFetch = vi.fn(async () => {
throw new Error("offline");
}) as unknown as typeof fetch;
expect(await detectOwnerType("danmt", badFetch)).toBeUndefined();
});
});
describe("`create` falls through into `register` — one implementation, not two", () => {
it("ends in exactly the Coolify calls `register` makes, with GitHub's own secrets", async () => {
const conversion = {
id: 424242,
slug: "hdb-coolify-prod",
client_id: "Iv23liXYZ",
client_secret: "github-issued-secret",
webhook_secret: "github-issued-webhook",
pem: privateKeyPem,
owner: { login: "heavy-duty", type: "Organization" },
};
// GitHub, mocked: owner type, the conversion, then the installation.
const githubFetch = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/conversions"))
return new Response(JSON.stringify(conversion), { status: 200 });
if (u.includes("/installation"))
return new Response(JSON.stringify({ id: 5150 }), { status: 200 });
return new Response(JSON.stringify({ type: "Organization" }), {
status: 200,
});
}) as unknown as typeof fetch;
const c = coolify({
"GET /github-apps": () => [200, []],
"POST /security/keys": () => [200, { uuid: "key-uuid-1" }],
"POST /github-apps": () => [200, { id: 11 }],
"GET /github-apps/11/repositories": () => [
200,
{ repositories: [{ full_name: "heavy-duty/incubator" }] },
],
});
const state = tmp("cast-state-");
// Drive the browser step: as soon as cast prints its start url, fetch the
// callback the way GitHub's redirect would.
const flow = createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "hdb-coolify-prod",
stateDir: state,
port: 0,
deps: {
fetchImpl: githubFetch,
openUrl: (url) => {
if (url.startsWith("http://127.0.0.1")) {
const u = new URL(url);
// The state parameter is not knowable from outside: read it off the
// page cast is serving, exactly as a browser would.
fetch(url)
.then((r) => r.text())
.then((page) => {
const state = /state=([^"&]+)/.exec(page)?.[1] ?? "";
return fetch(
`${u.origin}/callback?code=the-code&state=${state}`,
);
});
}
return false;
},
sleep: async () => {},
runGh: () => '{"role":"admin"}',
log: () => {},
},
});
const out = await flow;
// The fall-through, asserted as an identity of behaviour: the same four
// calls, in the same order, that the `register`-only test above pins.
expect(c.hits).toEqual([
"GET /github-apps",
"POST /security/keys",
"POST /github-apps",
"GET /github-apps/11/repositories",
]);
const body = c.bodies["POST /github-apps"] as Record<string, unknown>;
expect(body.app_id).toBe(424242);
// Recovered via the JWT path, NOT read off a setup_url redirect parameter
// (GitHub documents that one as a spoofable hint).
expect(body.installation_id).toBe(5150);
expect(body.client_secret).toBe("github-issued-secret");
expect(body.webhook_secret).toBe("github-issued-webhook");
expect(out.coolifyAppId).toBe(11);
// And the secrets landed, all three of them.
expect(
readFileSync(join(state, "github-apps", "hdb-coolify-prod.pem"), "utf8"),
).toBe(privateKeyPem);
});
// The blocker all three reviewers raised on #124, pinned. Conversion
// SUCCEEDS — GitHub has minted the App and shown the private key for the only
// time it ever will — and then the install poll fails for every attempt. The
// old order held that payload in memory across the whole poll and wrote it
// only inside registerGithubApp, so this scenario destroyed it.
it("keeps the one-shot PEM and client secret when the install NEVER lands", async () => {
const conversion = {
id: 424242,
slug: "hdb-coolify-prod",
client_id: "Iv23liXYZ",
client_secret: "github-issued-secret",
webhook_secret: "github-issued-webhook",
pem: privateKeyPem,
owner: { login: "heavy-duty", type: "Organization" },
};
const githubFetch = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/conversions"))
return new Response(JSON.stringify(conversion), { status: 200 });
// Never installed. 404 on every single attempt, which is the state the
// poll is designed to wait out and eventually give up on.
if (u.includes("/installation"))
return new Response("{}", { status: 404 });
return new Response(JSON.stringify({ type: "Organization" }), {
status: 200,
});
}) as unknown as typeof fetch;
const c = coolify({});
const state = tmp("cast-state-");
const err = await createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "hdb-coolify-prod",
stateDir: state,
port: 0,
deps: {
fetchImpl: githubFetch,
openUrl: (url) => {
if (url.startsWith("http://127.0.0.1")) {
const u = new URL(url);
fetch(url)
.then((r) => r.text())
.then((page) => {
const s = /state=([^"&]+)/.exec(page)?.[1] ?? "";
return fetch(`${u.origin}/callback?code=the-code&state=${s}`);
});
}
return false;
},
sleep: async () => {},
runGh: () => '{"role":"admin"}',
log: () => {},
installAttempts: 3,
installIntervalMs: 1,
},
}).then(
() => undefined,
(e: Error) => e,
);
expect(err).toBeDefined();
// 1. The secrets GitHub shows exactly once are ON DISK.
const pem = join(state, "github-apps", "hdb-coolify-prod.pem");
const json = join(state, "github-apps", "hdb-coolify-prod.json");
expect(readFileSync(pem, "utf8")).toBe(privateKeyPem);
const saved = JSON.parse(readFileSync(json, "utf8"));
expect(saved.client_secret).toBe("github-issued-secret");
expect(saved.webhook_secret).toBe("github-issued-webhook");
expect(saved.app_id).toBe(424242);
// The one field that is legitimately unknown, and the only one GitHub will
// answer again as many times as it is asked.
expect(saved.installation_id).toBeNull();
// 2. The remedy MATCHES REALITY — it names the files that exist and the
// command that finishes the job, and it does not claim credentials are
// saved somewhere they are not.
const message = (err as Error).message;
expect(message).toContain("Nothing is lost");
expect(message).toContain(pem);
expect(message).toContain(json);
expect(message).toContain("cast github-app register");
expect(message).toContain("--app-id 424242");
expect(message).toContain("Do NOT re-run `create`");
// 3. Nothing was registered with Coolify, so there is no half-record to
// reconcile — only an App on GitHub awaiting its install.
expect(c.hits).toEqual([]);
});
it("backfills the installation id onto the pending record rather than refusing itself", async () => {
const state = tmp("cast-state-");
const pending: PendingAppCredentials = {
appId: 12345,
clientId: "Iv23liABCDEF",
clientSecret: "cs-secret",
webhookSecret: "wh-secret",
privateKeyPem: privateKeyPem ?? "PEM",
};
const args = { stateDir: state, name: "app", org: "o", orgRepo: "o/r" };
const { secretsPath } = persistCredentials({ ...args, creds: pending });
expect(JSON.parse(readFileSync(secretsPath, "utf8")).installation_id).toBe(
null,
);
// The completion `create` performs once the install lands. This is the ONE
// transition allowed without --force, because nothing irreplaceable moves.
persistCredentials({
...args,
creds: { ...pending, installationId: 5150 },
});
expect(JSON.parse(readFileSync(secretsPath, "utf8")).installation_id).toBe(
5150,
);
// And it really is only that one field: a different client secret arriving
// alongside a filled-in installation id is still a refusal.
expect(() =>
persistCredentials({
...args,
creds: { ...pending, installationId: 5150, clientSecret: "other" },
}),
).toThrow(/refusing to overwrite/);
// Nor does a KNOWN installation id get quietly replaced by a different one.
expect(() =>
persistCredentials({
...args,
creds: { ...pending, installationId: 6000 },
}),
).toThrow(/refusing to overwrite/);
});
// claude-bot's addition: the post-conversion persist must never be the thing
// that throws, because at that moment it is holding the only copy of the key.
it("refuses a name collision BEFORE the browser flow, when nothing can be lost", async () => {
const state = tmp("cast-state-");
persistCredentials({
stateDir: state,
name: "hdb-coolify-prod",
creds: creds({ privateKeyPem: "an older App's key" }),
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
});
const c = coolify({});
const githubFetch = vi.fn(async () => {
throw new Error("GitHub must not be reached");
}) as unknown as typeof fetch;
await expect(
createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "hdb-coolify-prod",
stateDir: state,
port: 0,
ownerType: "Organization",
deps: {
fetchImpl: githubFetch,
runGh: () => '{"role":"admin"}',
log: () => {},
openUrl: () => false,
},
}),
).rejects.toThrow(/already has credentials on disk[\s\S]*register/);
// No browser flow, no App minted, no Coolify call — the whole point of
// checking now instead of after the conversion.
expect(c.hits).toEqual([]);
// And the older key is untouched.
expect(
readFileSync(join(state, "github-apps", "hdb-coolify-prod.pem"), "utf8"),
).toBe("an older App's key");
});
it("refuses before the browser dance when gh says you are not an org admin", async () => {
const c = coolify({});
await expect(
createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "n",
stateDir: tmp("cast-state-"),
port: 0,
ownerType: "Organization",
deps: {
runGh: () => '{"role":"member"}',
log: () => {},
openUrl: () => false,
},
}),
).rejects.toThrow(/not an admin of heavy-duty/);
// Nothing was served, nothing was registered — the point of a preflight.
expect(c.hits).toEqual([]);
});
});