feat: cast github-app create/register — run the App Manifest flow instead of transcribing it #124
7 changed files with 2633 additions and 38 deletions
54
CHANGELOG.md
54
CHANGELOG.md
|
|
@ -73,6 +73,53 @@ actually cutting it, and this file starts there.
|
|||
what it was told to write. Basic-auth-only is the safe slice until
|
||||
then.
|
||||
|
||||
- **`cast github-app create` / `cast github-app register`** (#7, superseding
|
||||
#5) — the GitHub App was the one piece of a Coolify instance cast could
|
||||
not reproduce: made by hand in a browser, its four identifiers copied out
|
||||
of the UI by eye, its private key downloaded to `~/Downloads`, its details
|
||||
fed to `scripts/register-github-app.sh` as a six-variable env pile.
|
||||
Nothing about that survived in state. There is no REST endpoint that
|
||||
creates a GitHub App — 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 your own browser session authenticates,
|
||||
followed by an unauthenticated code exchange. That exchange is the only
|
||||
moment GitHub ever yields the private key, the client secret and the
|
||||
webhook secret together, and cast now persists all three to
|
||||
`<state>/github-apps/` at 0600 under a `.gitignore` of `*`, so `git add
|
||||
-A` in the state repo cannot commit them by accident. `register` adopts
|
||||
credentials you already hold (an App made by hand, or a DR restore from a
|
||||
stored PEM) and reads the client secret from **stdin only** — argv is
|
||||
visible in `ps`. `create` does not reimplement it: it obtains credentials
|
||||
and then calls exactly the `register` path.
|
||||
|
||||
#5's three footguns are gone structurally rather than by validation. The
|
||||
Coolify-facing name is resolved from `github_apps.<org>/<repo>` in
|
||||
`environments.yaml` — the value every later `cast apply` resolves the App
|
||||
by — and `--name` only *seeds* an absent entry (keyed by full slug, #6),
|
||||
and is refused outright when it disagrees with one that exists. The state
|
||||
file is written only after the App is registered *and* verified, because a
|
||||
state file naming an App that does not work is worse than one naming none.
|
||||
`--webhook-secret` is optional: a webhook-inactive App is the correct shape
|
||||
for a tailnet-only Coolify, and nobody has to invent a placeholder.
|
||||
|
||||
Both verbs end at the step that matters most: `GET
|
||||
/github-apps/{id}/repositories`, asserting the repo is actually reachable.
|
||||
Until now a misconfigured App failed silently and surfaced hours later, in
|
||||
a different command, as an unresolvable source at `cast apply` time.
|
||||
|
||||
No new dependencies — `node:http` serves the callback, `node:crypto`'s
|
||||
`createSign("RSA-SHA256")` mints the App JWT that recovers the installation
|
||||
id from the App's own key (never from the `installation_id` GitHub appends
|
||||
to a redirect, which GitHub documents as a spoofable hint).
|
||||
|
||||
**Unvalidated, and load-bearing**: that GitHub accepts a `redirect_url` on
|
||||
`http://127.0.0.1:<port>` at all. The manifest docs are silent on the
|
||||
scheme, the precedent (Probot's setup flow) is strong, and validating it
|
||||
requires a logged-in GitHub session — so the first real run is an
|
||||
operator's, and README keeps the manual browser path documented until
|
||||
`create` has succeeded once.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`state:needs-human` no longer waits on the cron to become true** (#131)
|
||||
|
|
@ -161,6 +208,13 @@ actually cutting it, and this file starts there.
|
|||
block, because the password cannot be read and a block a rebuild cannot
|
||||
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`scripts/register-github-app.sh`** — replaced by `cast github-app
|
||||
register`. Kept as a thin wrapper it would have preserved exactly the
|
||||
interface #5 catalogued as producing three live footguns, while adding a
|
||||
second surface to keep in step with the CLI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
||||
|
|
|
|||
129
README.md
129
README.md
|
|
@ -119,6 +119,9 @@ cast inventory <org>/<repo> --env <env>
|
|||
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
|
||||
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
|
||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765]
|
||||
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id> \
|
||||
--client-id <id> --client-secret-stdin --private-key <file>
|
||||
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||
cast team [--env <env>]
|
||||
```
|
||||
|
|
@ -166,6 +169,12 @@ cast team [--env <env>]
|
|||
environment's name at a plan that says, for every database, whether it is backed
|
||||
up and when the last backup landed. See *Tearing an environment down* below.
|
||||
- **`server add`** — uploads a server's private key and registers it with Coolify.
|
||||
- **`github-app create`** — creates the GitHub App Coolify clones private repos
|
||||
with, by running GitHub's App Manifest flow, then registers it. Two browser
|
||||
clicks, zero transcription. See *The GitHub App* below.
|
||||
- **`github-app register`** — adopts an App you already hold: one created by hand,
|
||||
or a disaster-recovery restore from a stored private key. `create` ends by
|
||||
running exactly this.
|
||||
- **`smoke`** — contract test against the project's `smoke_target`: proves
|
||||
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
|
||||
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
|
||||
|
|
@ -208,6 +217,121 @@ no credentials at all it says so, and names the fix.
|
|||
The token is never put in the clone URL or in `http.extraheader` — both leak it
|
||||
into `ps`, and the latter persists it into the clone's git config.
|
||||
|
||||
## The GitHub App: `cast github-app`
|
||||
|
||||
That is how *cast* clones. **Coolify** clones with a GitHub App, and the App used
|
||||
to be the one piece of a Coolify instance cast could not reproduce: created by
|
||||
hand in a browser, its four identifiers copied out of the UI by eye, its private
|
||||
key downloaded to `~/Downloads`, its details fed to a shell script as a
|
||||
six-variable env pile. Nothing about that survived in state. Rebuild the instance
|
||||
and you redid the hoops from memory.
|
||||
|
||||
```sh
|
||||
cast github-app create heavy-duty/incubator --env prod --name hdb-coolify-prod
|
||||
```
|
||||
|
||||
There is **no REST endpoint that creates a GitHub App** — no `POST /apps`, no
|
||||
GraphQL mutation, no `gh app` subcommand, and no PAT scope that unlocks one. The
|
||||
only programmatic path is GitHub's [App Manifest
|
||||
flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest):
|
||||
a browser form POST whose authentication is your existing GitHub session,
|
||||
followed by an unauthenticated code exchange. It is how Coolify's own *Create
|
||||
GitHub App* button works, and it is why this command serves you a page instead of
|
||||
calling an API.
|
||||
|
||||
What `create` does:
|
||||
|
||||
1. If `gh` is on `PATH` and authenticated, checks you are an **admin** of the org
|
||||
— so you learn you cannot create Apps there *before* the browser dance, not
|
||||
after. `gh` is never required; an absent one skips the check silently.
|
||||
2. Resolves the App's Coolify-facing name from **`github_apps.<org>/<repo>` in
|
||||
`environments.yaml`**, which is what every later `cast apply` resolves this
|
||||
repo's App by. `--name` seeds that entry when it is absent and is **refused**
|
||||
when it disagrees with one that exists.
|
||||
3. Serves a one-shot page on `127.0.0.1` that submits an App manifest —
|
||||
`contents: read` + `metadata: read`, webhook inactive, private.
|
||||
4. You click *Create GitHub App*; GitHub redirects back to the loopback server,
|
||||
which checks the CSRF `state` and shuts down.
|
||||
5. Exchanges the code. **This response is the only moment GitHub ever hands over
|
||||
the private key, the client secret and the webhook secret together.**
|
||||
6. Prints (and tries to open) the install URL; you pick the repository.
|
||||
7. Recovers the installation id by minting an RS256 JWT with the App's own key —
|
||||
never from the `installation_id` GitHub appends to a redirect, which GitHub
|
||||
documents as a spoofable hint.
|
||||
8. Uploads the key to Coolify and creates the App record.
|
||||
9. **Asks Coolify which repositories the App can actually see, and fails if
|
||||
`<org>/<repo>` is not among them.** This is the step that matters most:
|
||||
without it a misconfigured App fails silently and surfaces hours later, in a
|
||||
different command, as an unresolvable source at `cast apply` time.
|
||||
|
||||
`register` is the same command from step 8 onwards, for an App you already hold —
|
||||
one made by hand, or a disaster-recovery restore from a stored PEM:
|
||||
|
||||
```sh
|
||||
pbpaste | cast github-app register heavy-duty/incubator --env prod \
|
||||
--app-id 12345 --installation-id 99887766 --client-id Iv23li… \
|
||||
--client-secret-stdin --private-key ~/Downloads/app.private-key.pem
|
||||
```
|
||||
|
||||
The client secret is read from **stdin only** — argv is visible in `ps` and kept
|
||||
in shell history. `--webhook-secret` is optional: a webhook-**inactive** App is
|
||||
the right shape for a tailnet-only Coolify where deliveries can never arrive and
|
||||
deploys are CI-triggered, and cast generates a value rather than making you
|
||||
invent one.
|
||||
|
||||
### Where the credentials land
|
||||
|
||||
Into the state directory you point cast at — cast itself stores nothing:
|
||||
|
||||
```
|
||||
<state>/github-apps/
|
||||
├── .gitignore # `*` — written by cast
|
||||
├── <name>.pem # 0600, the private key
|
||||
└── <name>.json # 0600, app id, installation id, client id + secret, webhook secret
|
||||
```
|
||||
|
||||
All three secrets, because GitHub shows them once and `register` needs the client
|
||||
secret to be re-runnable at all — a disaster-recovery restore that is missing it
|
||||
is not a restore. They are written **plaintext at 0600**, not into `secrets/`:
|
||||
that store holds per-repo-per-env *application* env vars, whose whole purpose is
|
||||
to be decrypted and injected into the running container, which is the last place
|
||||
an App private key belongs — and its age identity may not exist on the machine
|
||||
doing the bootstrap at all. Encrypting the one credential that makes recovery
|
||||
possible behind a key that might not be there is how DR fails at the moment it is
|
||||
needed.
|
||||
|
||||
So the guard is structural rather than cryptographic: the `.gitignore` means
|
||||
`git add -A` in your state repo cannot commit these by accident. Committing them
|
||||
stays possible and has to be deliberate — encrypt them yourself and commit the
|
||||
ciphertext, or keep the directory out of the repo and back it up somewhere that
|
||||
is not a git remote.
|
||||
|
||||
### Until it has worked once
|
||||
|
||||
`create`'s design rests on GitHub accepting a `redirect_url` on
|
||||
`http://127.0.0.1:<port>`. The manifest docs are silent on the scheme (loopback
|
||||
HTTP is documented for *OAuth* redirect URIs), and the precedent is strong —
|
||||
Probot's setup flow does exactly this — but it is unvalidated, because validating
|
||||
it needs a logged-in GitHub session. **The manual path below stays supported
|
||||
until `create` has succeeded against a real GitHub once.** If it fails, create
|
||||
the App by hand in the browser and use `github-app register`, which does not
|
||||
depend on the assumption at all.
|
||||
|
||||
<details>
|
||||
<summary>The manual path</summary>
|
||||
|
||||
1. Org → Settings → Developer settings → GitHub Apps → **New GitHub App**.
|
||||
Permissions: **Contents: Read-only**, **Metadata: Read-only**. Uncheck
|
||||
*Active* under Webhook. Uncheck *Any account* (keep it private).
|
||||
2. Note the **App ID** and **Client ID**; generate a **client secret**; generate
|
||||
and download a **private key**.
|
||||
3. **Install App** → pick the repository. The installation id is the last path
|
||||
segment of the URL you land on (`…/settings/installations/<id>`).
|
||||
4. Feed all of it to `cast github-app register` (above), which validates the name
|
||||
against state and verifies the repo is reachable.
|
||||
|
||||
</details>
|
||||
|
||||
## Many Coolifys
|
||||
|
||||
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of
|
||||
|
|
@ -1003,8 +1127,9 @@ the way back to zero from a half-applied first run.
|
|||
|
||||
## Scripts
|
||||
|
||||
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
|
||||
Coolify, restore a database backup into a target container.
|
||||
Operational helpers, all argument-driven (`scripts/`): restore a database backup
|
||||
into a target container. (`register-github-app.sh` is gone — it is
|
||||
`cast github-app register` now.)
|
||||
|
||||
**They run where cast runs — off the box.** They drive the Coolify API, or reach a
|
||||
box over SSH; none of them expects to be executing *on* a server. Anything that
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Register a GitHub App (created via the manifest flow on your org) with a
|
||||
# Coolify instance, so cast can create applications from private repos.
|
||||
#
|
||||
# usage: CAST_STATE=~/infra ./register-github-app.sh
|
||||
#
|
||||
# Takes everything as input — the app's identity is yours, not this tool's.
|
||||
# APP_NAME must match the `github_apps.<repo>` value in your environments.yaml:
|
||||
# that is the name cast resolves when it creates an application.
|
||||
set -euo pipefail
|
||||
|
||||
STATE="${CAST_STATE:-.}"
|
||||
# shellcheck disable=SC1091
|
||||
source "${STATE}/.coolify.env"
|
||||
|
||||
: "${APP_NAME:?the Coolify-facing GitHub App name (must match github_apps.<repo> in environments.yaml)}"
|
||||
: "${ORG:?the GitHub org or user the App is installed on}"
|
||||
: "${APP_ID:?}"; : "${INSTALLATION_ID:?}"; : "${CLIENT_ID:?}"; : "${CLIENT_SECRET:?}"
|
||||
: "${WEBHOOK_SECRET:?}"; : "${PRIVATE_KEY_FILE:?path to the App private key PEM}"
|
||||
|
||||
api() { curl -fsS -H "Authorization: Bearer ${COOLIFY_ACCESS_TOKEN}" -H "Content-Type: application/json" "$@"; }
|
||||
|
||||
KEY_UUID=$(api -X POST "${COOLIFY_BASE_URL}/api/v1/security/keys" \
|
||||
-d "$(jq -n --arg name "${APP_NAME}-key" --rawfile pk "$PRIVATE_KEY_FILE" \
|
||||
'{name:$name, private_key:$pk}')" | jq -r .uuid)
|
||||
|
||||
api -X POST "${COOLIFY_BASE_URL}/api/v1/github-apps" -d "$(jq -n \
|
||||
--arg name "$APP_NAME" --arg org "$ORG" \
|
||||
--arg app_id "$APP_ID" --arg inst "$INSTALLATION_ID" --arg cid "$CLIENT_ID" \
|
||||
--arg csec "$CLIENT_SECRET" --arg wh "$WEBHOOK_SECRET" --arg key "$KEY_UUID" \
|
||||
--arg api_url "https://api.github.com" --arg html_url "https://github.com" \
|
||||
'{name:$name, organization:$org, api_url:$api_url, html_url:$html_url,
|
||||
app_id:($app_id|tonumber), installation_id:($inst|tonumber), client_id:$cid, client_secret:$csec,
|
||||
webhook_secret:$wh, private_key_uuid:$key}')"
|
||||
|
||||
echo "github app registered as ${APP_NAME}"
|
||||
172
src/cli.ts
172
src/cli.ts
|
|
@ -84,6 +84,13 @@ import {
|
|||
renderFleetDiff,
|
||||
renderProjectHeading,
|
||||
} from "./fleet.js";
|
||||
import {
|
||||
createGithubApp,
|
||||
generateWebhookSecret,
|
||||
registerGithubApp,
|
||||
resolveAppName,
|
||||
seedGithubAppBinding,
|
||||
} from "./github-app.js";
|
||||
import {
|
||||
type LiveResource,
|
||||
type SweepEnvironment,
|
||||
|
|
@ -124,6 +131,10 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
cast inventory --env <env> --emit-draft <dir> [--recipient age1…] [--no-secrets]
|
||||
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
|
||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765] [--force]
|
||||
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id>
|
||||
--client-id <id> --client-secret-stdin --private-key <file>
|
||||
[--webhook-secret <v>] [--name <n>] [--force]
|
||||
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||
cast team [--env <env>]
|
||||
cast versions # list installed versions
|
||||
|
|
@ -179,6 +190,25 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
coordinate: --path, --project, --environment, --resource,
|
||||
--hostname-overlay.
|
||||
|
||||
github-app (the credential Coolify clones private repos with):
|
||||
create runs GitHub's App Manifest flow — the ONLY programmatic way to make a
|
||||
GitHub App — then falls through into exactly what \`register\` does. It
|
||||
serves a one-shot page on 127.0.0.1, your browser session authenticates
|
||||
the form, and the conversion response hands over the private key, the
|
||||
client secret and the webhook secret in one body. Nothing is transcribed.
|
||||
register adopts credentials you already hold: an App created by hand, or a
|
||||
disaster-recovery restore from a stored PEM. The client secret is read
|
||||
from STDIN (never argv); --webhook-secret is optional, because a
|
||||
webhook-INACTIVE App is the right shape for a tailnet-only Coolify.
|
||||
--name seeds \`github_apps.<org>/<repo>\` in environments.yaml when it is
|
||||
ABSENT, and is refused when it disagrees with an entry that exists.
|
||||
The state file is the authority: its value is what every later
|
||||
\`cast apply\` resolves this repo's App by.
|
||||
--force overwrite an existing PEM/credentials file under <state>/github-apps/.
|
||||
Both verbs end by asking Coolify which repositories the App can actually see
|
||||
and failing if <org>/<repo> is not among them — the check that turns a silent
|
||||
misconfiguration into an error next to the thing that caused it.
|
||||
|
||||
capture (adopt a hand-built instance into the age secret store):
|
||||
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
|
||||
for a manifest that has not declared generated_secrets yet.
|
||||
|
|
@ -1423,6 +1453,145 @@ function formatVersion(): string {
|
|||
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
|
||||
}
|
||||
|
||||
// `--client-secret-stdin`, mirroring `docker login --password-stdin`: argv is
|
||||
// visible in `ps` and lands in shell history, and a GitHub App client secret is
|
||||
// shown by GitHub exactly once.
|
||||
async function readAllStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8").trim();
|
||||
}
|
||||
|
||||
// `cast github-app register|create` — one command surface, ONE registration
|
||||
// implementation (see src/github-app.ts). Everything up to the point where
|
||||
// credentials exist differs between the two verbs; everything from there on is
|
||||
// registerGithubApp, which `create` calls rather than reimplements.
|
||||
async function githubAppCommand(rest: string[]): Promise<number> {
|
||||
const verb = rest[0];
|
||||
if (verb !== "register" && verb !== "create") {
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest.slice(1),
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
state: { type: "string" },
|
||||
env: { type: "string" },
|
||||
instance: { type: "string" },
|
||||
name: { type: "string" },
|
||||
force: { type: "boolean" },
|
||||
// create
|
||||
port: { type: "string" },
|
||||
// register
|
||||
"app-id": { type: "string" },
|
||||
"installation-id": { type: "string" },
|
||||
"client-id": { type: "string" },
|
||||
"client-secret-stdin": { type: "boolean" },
|
||||
"private-key": { type: "string" },
|
||||
"webhook-secret": { type: "string" },
|
||||
},
|
||||
});
|
||||
const orgRepo = positionals[0];
|
||||
// --env is required for the same reason `server add` requires it: this
|
||||
// writes to a live Coolify, and every write first asserts that the token
|
||||
// belongs to the environment's declared team.
|
||||
if (!orgRepo || !orgRepo.includes("/") || !values.env) {
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
const stateDir = stateDirFrom(values.state);
|
||||
const bindingsPath = join(stateDir, "environments.yaml");
|
||||
const bindings = loadBindings(bindingsPath);
|
||||
const binding = bindings.environments[values.env];
|
||||
if (!binding) {
|
||||
console.error(`environment ${values.env} not in environments.yaml`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Step 2, before anything reaches a network: the Coolify-facing name comes
|
||||
// from state. See resolveAppName — this is #5's footgun 1, dissolved.
|
||||
const { name, seed } = resolveAppName({
|
||||
bindings,
|
||||
orgRepo,
|
||||
nameFlag: values.name,
|
||||
});
|
||||
|
||||
const { instance, client } = openCoolify(stateDir, values.instance, binding);
|
||||
assertWritable(instance, `github-app ${verb}`);
|
||||
const team = await assertTeam(client, binding.team, values.env);
|
||||
console.log(`team ${formatTeam(team)} ✓`);
|
||||
console.log(
|
||||
`github app name: ${name}${seed ? " (from --name, not yet in environments.yaml)" : " (from environments.yaml)"}`,
|
||||
);
|
||||
|
||||
const org = orgRepo.split("/")[0] ?? orgRepo;
|
||||
if (verb === "create") {
|
||||
await createGithubApp({
|
||||
client,
|
||||
orgRepo,
|
||||
name,
|
||||
stateDir,
|
||||
force: values.force,
|
||||
port: values.port ? Number(values.port) : undefined,
|
||||
});
|
||||
} else {
|
||||
const appId = values["app-id"];
|
||||
const installationId = values["installation-id"];
|
||||
const clientId = values["client-id"];
|
||||
const privateKey = values["private-key"];
|
||||
if (!appId || !installationId || !clientId || !privateKey) {
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
if (!values["client-secret-stdin"]) {
|
||||
console.error(
|
||||
"--client-secret-stdin is required: the client secret is read from stdin,\nnever from argv (which `ps` shows and shell history keeps).",
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
const clientSecret = await readAllStdin();
|
||||
if (!clientSecret) {
|
||||
console.error("no client secret on stdin");
|
||||
return 2;
|
||||
}
|
||||
// #5's footgun 3: a webhook-inactive App is the right configuration for a
|
||||
// tailnet-only Coolify, and the old script still demanded a secret for it.
|
||||
const webhookSecret = values["webhook-secret"] ?? generateWebhookSecret();
|
||||
if (!values["webhook-secret"]) {
|
||||
console.log(
|
||||
"no --webhook-secret: generated one (fine for a webhook-inactive App)",
|
||||
);
|
||||
}
|
||||
await registerGithubApp({
|
||||
client,
|
||||
name,
|
||||
org,
|
||||
orgRepo,
|
||||
stateDir,
|
||||
force: values.force,
|
||||
creds: {
|
||||
appId: Number(appId),
|
||||
installationId: Number(installationId),
|
||||
clientId,
|
||||
clientSecret,
|
||||
webhookSecret,
|
||||
privateKeyPem: readFileSync(privateKey, "utf8"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Only after the App is registered AND verified: a state file that names an
|
||||
// App which does not work is worse than one that names none.
|
||||
if (seed) {
|
||||
seedGithubAppBinding(bindingsPath, orgRepo, name);
|
||||
console.log(
|
||||
`environments.yaml: github_apps["${orgRepo}"] = ${name} (added)`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
if (command === "-h" || command === "--help" || command === "help") {
|
||||
|
|
@ -2298,6 +2467,9 @@ async function main(): Promise<number> {
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (command === "github-app") {
|
||||
return await githubAppCommand(rest);
|
||||
}
|
||||
if (command === "smoke") {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest,
|
||||
|
|
|
|||
1069
src/github-app.ts
Normal file
1069
src/github-app.ts
Normal file
File diff suppressed because it is too large
Load diff
280
test/github-app-register-cli.test.ts
Normal file
280
test/github-app-register-cli.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
931
test/github-app.test.ts
Normal file
931
test/github-app.test.ts
Normal file
|
|
@ -0,0 +1,931 @@
|
|||
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,
|
||||
awaitInstallationId,
|
||||
buildManifest,
|
||||
convertManifestCode,
|
||||
createGithubApp,
|
||||
detectOwnerType,
|
||||
findInstallationId,
|
||||
manifestFormPage,
|
||||
mintAppJwt,
|
||||
newAppFormAction,
|
||||
persistCredentials,
|
||||
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(""");
|
||||
// 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({
|
||||
"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([
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
"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 three
|
||||
// calls, in the same order, that the `register`-only test above pins.
|
||||
expect(c.hits).toEqual([
|
||||
"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);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue