1312 lines
45 KiB
TypeScript
1312 lines
45 KiB
TypeScript
import { createVerify, generateKeyPairSync } from "node:crypto";
|
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
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";
|
|
import { tmp } from "./helpers/tmp.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 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(""");
|
|
// 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([]);
|
|
});
|
|
});
|