diff --git a/test-companion/src/app-auth.mjs b/test-companion/src/app-auth.mjs index 725c524..ed62421 100644 --- a/test-companion/src/app-auth.mjs +++ b/test-companion/src/app-auth.mjs @@ -23,6 +23,47 @@ export function jwtClaims(appId, nowSeconds) { return { iat, exp: iat + JWT_LIFETIME_SECONDS, iss: String(appId) } } +/** + * Call the GitHub API as the App. Not through gh: gh sends `Authorization: token`, + * and GitHub accepts an App's JWT only as `Authorization: Bearer`. + * Returns { ok, status, data, reason }; `reason` is GitHub's message, never the JWT. + */ +export async function appApi(path, { jwt, method = "GET", body, apiUrl, fetchImpl = fetch } = {}) { + const base = String(apiUrl || "https://api.github.com").replace(/\/+$/, "") + let response + try { + response = await fetchImpl(`${base}/${path}`, { + method, + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (e) { + return { ok: false, status: 0, data: undefined, reason: `could not reach GitHub: ${e?.message ?? e}` } + } + const data = await response.json().catch(() => undefined) + const reason = response.ok ? "" : `${data?.message || response.statusText || "no detail from GitHub"} (HTTP ${response.status})` + return { ok: response.ok, status: response.status, data, reason } +} + +/** What to log when looking up the App's installation on this repository fails. */ +export function installationLookupMessage(result) { + const reason = result?.reason || "no detail from GitHub" + if (result?.status === 404) { + return { level: "notice", text: "Test Companion: the GitHub App is not installed on this repository; using the workflow token." } + } + return { + level: "warning", + text: + `Test Companion: could not sign in as the GitHub App (${reason}); using the workflow token. ` + + "Check that app-id is the App's numeric ID and app-private-key is a key generated for that App.", + } +} + export function signJwt({ appId, privateKey, now = Date.now() / 1000 }) { const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" })) const payload = b64url(JSON.stringify(jwtClaims(appId, now))) diff --git a/test-companion/src/main.mjs b/test-companion/src/main.mjs index 4d87881..a93827c 100644 --- a/test-companion/src/main.mjs +++ b/test-companion/src/main.mjs @@ -14,7 +14,13 @@ import { canIsolate, prepareSandbox, stopSandbox, handToAgent, writeAgentEnv, re import { agentEnvFrom, asAgentCommand, checkPrHandoff, readClaimResult } from "./sandbox-policy.mjs" import { startRelay } from "./relay.mjs" import { runCapturingAnswer } from "./answer.mjs" -import { appCredentials, signJwt, installationTokenRequest } from "./app-auth.mjs" +import { + appCredentials, + signJwt, + installationTokenRequest, + appApi, + installationLookupMessage, +} from "./app-auth.mjs" import { OUTCOMES, parseSummary, resolveOutcome, checkConclusion, stepShouldFail, approvalWasSpent } from "./outcome.mjs" import { renderJobSummary, @@ -162,7 +168,7 @@ function fetchAndCheckout(sha, branch) { } /** A repo-scoped App installation token, or undefined to fall back to the workflow token. */ -function appInstallationToken() { +async function appInstallationToken() { const creds = appCredentials(env) if (!creds) return undefined // Remove the key from the environment so nothing started later, the agent included, inherits it. @@ -177,24 +183,22 @@ function appInstallationToken() { } // The JWT authenticates the App; only the installation token can touch a repository. - const asApp = (args) => runGh(args, { token: jwt }) + const asApp = (path, opts) => appApi(path, { jwt, apiUrl: env.GITHUB_API_URL, ...opts }) - const found = asApp(["api", `repos/${REPO}/installation`]) - if (found.status !== 0) { - notice("Test Companion: the GitHub App is not installed on this repository; using the workflow token.") + const found = await asApp(`repos/${REPO}/installation`) + if (!found.ok) { + const { level, text } = installationLookupMessage(found) + if (level === "warning") warn(text) + else notice(text) return undefined } - let installationId - let appLogin - try { - const installation = JSON.parse(found.stdout) - installationId = installation?.id - // Adopted only once a token is minted, so a failed mint can't leave us looking under the wrong login. - appLogin = installation?.app_slug ? `${installation.app_slug}[bot]` : undefined - } catch { + const installationId = found.data?.id + // Adopted only once a token is minted, so a failed mint can't leave us looking under the wrong login. + const appLogin = found.data?.app_slug ? `${found.data.app_slug}[bot]` : undefined + if (!installationId) { + warn("Test Companion: GitHub's reply about the App's installation had no ID; using the workflow token.") return undefined } - if (!installationId) return undefined // Scoped to this repository; an empty request gets every repository the installation covers. const scope = installationTokenRequest(REPO) @@ -203,29 +207,21 @@ function appInstallationToken() { return undefined } - const mint = (body) => - runGh(["api", `app/installations/${installationId}/access_tokens`, "--method", "POST", "--input", "-"], { - token: jwt, - input: JSON.stringify(body), - }) - // No retry with a smaller request: dropping the permission list asks for everything the installation holds. - const minted = mint(scope) - if (minted.status !== 0) { + const minted = await asApp(`app/installations/${installationId}/access_tokens`, { method: "POST", body: scope }) + if (!minted.ok) { warn( - "Could not get a GitHub App token with the permissions Test Companion needs; using the workflow token instead. " + + `Test Companion: could not get a GitHub App token with the permissions it needs (${minted.reason}); using the workflow token instead. ` + "If the App's permissions were changed, an organisation owner may need to accept them.", ) return undefined } - let token - try { - token = JSON.parse(minted.stdout)?.token - } catch { + const token = minted.data?.token + if (!token) { + warn("Test Companion: GitHub's reply had no App token; using the workflow token.") return undefined } - if (!token) return undefined // Mask before first use. console.log(`::add-mask::${token}`) @@ -381,7 +377,7 @@ async function main() { } // Act as the App when installed, before anything else talks to GitHub. - const appToken = appInstallationToken() + const appToken = await appInstallationToken() if (appToken) { env.GH_TOKEN = appToken notice("Test Companion: acting as the GitHub App.") diff --git a/test-companion/test/app-auth.test.mjs b/test-companion/test/app-auth.test.mjs index 872d454..4bd6305 100644 --- a/test-companion/test/app-auth.test.mjs +++ b/test-companion/test/app-auth.test.mjs @@ -1,7 +1,14 @@ import { test, describe } from "node:test" import assert from "node:assert/strict" import { generateKeyPairSync, createVerify } from "node:crypto" -import { appCredentials, jwtClaims, signJwt, installationTokenRequest } from "../src/app-auth.mjs" +import { + appCredentials, + jwtClaims, + signJwt, + installationTokenRequest, + appApi, + installationLookupMessage, +} from "../src/app-auth.mjs" const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }) const pem = privateKey.export({ type: "pkcs1", format: "pem" }) @@ -88,3 +95,61 @@ describe("installationTokenRequest", () => { assert.equal(installationTokenRequest(undefined), undefined) }) }) + +describe("appApi", () => { + const fakeFetch = (status, json, seen) => async (url, init) => { + Object.assign(seen, { url, init }) + return { ok: status >= 200 && status < 300, status, statusText: "", json: async () => json } + } + + test("sends the JWT as Bearer, which GitHub requires for an App", async () => { + const seen = {} + const r = await appApi("repos/o/r/installation", { jwt: "J", fetchImpl: fakeFetch(200, { id: 7 }, seen) }) + assert.equal(seen.init.headers.Authorization, "Bearer J") + assert.equal(seen.url, "https://api.github.com/repos/o/r/installation") + assert.deepEqual(r, { ok: true, status: 200, data: { id: 7 }, reason: "" }) + }) + + test("uses the server's API address when given", async () => { + const seen = {} + await appApi("x", { jwt: "J", apiUrl: "https://ghe.example/api/v3/", fetchImpl: fakeFetch(200, {}, seen) }) + assert.equal(seen.url, "https://ghe.example/api/v3/x") + }) + + test("sends a JSON body on POST", async () => { + const seen = {} + await appApi("x", { jwt: "J", method: "POST", body: { a: 1 }, fetchImpl: fakeFetch(201, {}, seen) }) + assert.equal(seen.init.method, "POST") + assert.equal(seen.init.body, '{"a":1}') + assert.equal(seen.init.headers["Content-Type"], "application/json") + }) + + test("a failure carries GitHub's message and status, never the JWT", async () => { + const r = await appApi("x", { jwt: "SECRET-JWT", fetchImpl: fakeFetch(401, { message: "Bad credentials" }, {}) }) + assert.equal(r.ok, false) + assert.equal(r.reason, "Bad credentials (HTTP 401)") + assert.doesNotMatch(r.reason, /SECRET-JWT/) + }) + + test("a network failure is reported, not thrown", async () => { + const r = await appApi("x", { jwt: "J", fetchImpl: async () => { throw new Error("ECONNRESET") } }) + assert.equal(r.ok, false) + assert.equal(r.status, 0) + assert.match(r.reason, /ECONNRESET/) + }) +}) + +describe("installationLookupMessage", () => { + test("only a 404 means the App is not installed", () => { + const m = installationLookupMessage({ status: 404, reason: "Not Found (HTTP 404)" }) + assert.equal(m.level, "notice") + assert.match(m.text, /not installed on this repository/) + }) + + test("any other failure is a warning that carries GitHub's reason", () => { + const m = installationLookupMessage({ status: 401, reason: "A JSON web token could not be decoded (HTTP 401)" }) + assert.equal(m.level, "warning") + assert.match(m.text, /A JSON web token could not be decoded \(HTTP 401\)/) + assert.doesNotMatch(m.text, /not installed/) + }) +})