| 1 | // End-to-end GitHub-compatible apps (src/apps/, src/http/apps.ts): a real `wrangler dev` Worker, an |
| 2 | // app registered by a person, installed on some of their repositories, and then driven the way a |
| 3 | // GitHub App integration (a deploy service, a CI or review bot) drives GitHub: an RS256 JWT signed |
| 4 | // with its private key, installation access tokens, the unmodified @octokit/rest client, Git over |
| 5 | // HTTPS with x-access-token, and signed webhooks. |
| 6 | // |
| 7 | // pnpm --filter @diffinery/app exec node --test test/e2e/apps.test.mjs |
| 8 | // |
| 9 | // Covers: registering (people in a browser only), the app's JWT (and expired, wrong-key and unknown |
| 10 | // ones), installing on all or selected repositories (only by admins of the account, private apps only |
| 11 | // on their owner), installation tokens narrowed by repository and permission, per-endpoint permissions, |
| 12 | // workflow files refused, Git clone and push, webhooks (installation, installation_repositories, push) |
| 13 | // with their signatures, suspending, new permissions waiting for an admin, revoking, uninstalling and |
| 14 | // deleting the app. |
| 15 | import "./fresh-connections.mjs"; |
| 16 | import { spawn, execFileSync } from "node:child_process"; |
| 17 | import { createHash, createHmac, createPrivateKey, createSign, generateKeyPairSync, randomBytes } from "node:crypto"; |
| 18 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 19 | import { createServer } from "node:http"; |
| 20 | import { tmpdir } from "node:os"; |
| 21 | import { join, dirname, resolve } from "node:path"; |
| 22 | import { fileURLToPath } from "node:url"; |
| 23 | import { test, before, after } from "node:test"; |
| 24 | import assert from "node:assert/strict"; |
| 25 | import { Octokit } from "@octokit/rest"; |
| 26 | import { chromium } from "playwright"; |
| 27 | import { migratedState } from "./d1-state.mjs"; |
| 28 | import { freePort } from "./ports.mjs"; |
| 29 | |
| 30 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 31 | const work = mkdtempSync(join(tmpdir(), "diffinery-apps-")); |
| 32 | let base; |
| 33 | let dev; |
| 34 | let devLog = ""; |
| 35 | let persist; |
| 36 | let hooks; |
| 37 | let browser; |
| 38 | /** Webhook deliveries the app's server received: { event, delivery, signature, body, json }. */ |
| 39 | const received = []; |
| 40 | |
| 41 | const ADA = "ada-browser-session-0123456789abcdef"; |
| 42 | const BOB = "bob-browser-session-0123456789abcdef"; |
| 43 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 44 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 45 | |
| 46 | async function until(what, fn, timeoutMs = 60_000) { |
| 47 | const end = Date.now() + timeoutMs; |
| 48 | while (Date.now() < end) { |
| 49 | const v = await fn(); |
| 50 | if (v) return v; |
| 51 | await sleep(300); |
| 52 | } |
| 53 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 54 | } |
| 55 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 56 | const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command); |
| 57 | |
| 58 | before(async () => { |
| 59 | const port = await freePort(); |
| 60 | base = `http://localhost:${port}`; |
| 61 | persist = join(work, "state"); |
| 62 | migratedState(APP, persist); |
| 63 | const at = new Date().toISOString(); |
| 64 | const later = new Date(Date.now() + 86400_000).toISOString(); |
| 65 | sql( |
| 66 | [ |
| 67 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, |
| 68 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('bob1', 'bob', 'user', 'Bob', 'bob@example.com', 'active', '${at}')`, |
| 69 | `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('acme1', 'acme', 'org', 'Acme', 'active', '${at}')`, |
| 70 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme1', 'ada1', 'owner', '${at}'), ('acme1', 'bob1', 'member', '${at}')`, |
| 71 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA)}', 'ada1', 'passkey', '${at}', '${at}', '${later}'), ('${hash(BOB)}', 'bob1', 'passkey', '${at}', '${at}', '${later}')`, |
| 72 | ].join("; "), |
| 73 | ); |
| 74 | // The app's own server: it receives the webhooks. |
| 75 | hooks = createServer((req, res) => { |
| 76 | let body = ""; |
| 77 | req.on("data", (d) => (body += d)); |
| 78 | req.on("end", () => { |
| 79 | received.push({ event: req.headers["x-github-event"], delivery: req.headers["x-github-delivery"], signature: req.headers["x-hub-signature-256"], body, json: JSON.parse(body) }); |
| 80 | res.writeHead(200).end("ok"); |
| 81 | }); |
| 82 | }); |
| 83 | await new Promise((r) => hooks.listen(0, "127.0.0.1", r)); |
| 84 | dev = spawn( |
| 85 | "pnpm", |
| 86 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], |
| 87 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 88 | ); |
| 89 | dev.stdout.on("data", (d) => (devLog += d)); |
| 90 | dev.stderr.on("data", (d) => (devLog += d)); |
| 91 | await until( |
| 92 | "wrangler dev", |
| 93 | async () => { |
| 94 | try { |
| 95 | return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; |
| 96 | } catch { |
| 97 | return false; |
| 98 | } |
| 99 | }, |
| 100 | 120_000, |
| 101 | ); |
| 102 | }); |
| 103 | |
| 104 | after(async () => { |
| 105 | await browser?.close(); |
| 106 | hooks?.close(); |
| 107 | if (dev?.pid) { |
| 108 | try { |
| 109 | process.kill(-dev.pid, "SIGTERM"); |
| 110 | } catch {} |
| 111 | } |
| 112 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 113 | }); |
| 114 | |
| 115 | /** The REST API as a person's browser session (`{ cookie }`), a bearer credential, or anonymously. */ |
| 116 | async function api(method, path, body, as) { |
| 117 | const headers = { "content-type": "application/json" }; |
| 118 | if (typeof as === "string") headers.authorization = `Bearer ${as}`; |
| 119 | else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; |
| 120 | const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 121 | const text = await res.text(); |
| 122 | let json = null; |
| 123 | try { |
| 124 | json = text ? JSON.parse(text) : null; |
| 125 | } catch { |
| 126 | json = text; |
| 127 | } |
| 128 | return { status: res.status, json }; |
| 129 | } |
| 130 | const ada = { cookie: ADA }; |
| 131 | const bob = { cookie: BOB }; |
| 132 | async function ok(method, path, body, as, status) { |
| 133 | const r = await api(method, path, body, as); |
| 134 | assert.ok(status ? r.status === status : r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 135 | return r.json; |
| 136 | } |
| 137 | |
| 138 | /** An app's JWT, as @octokit/auth-app and every GitHub App library make it. */ |
| 139 | function appJwt(pem, iss, o = {}) { |
| 140 | const now = Math.floor(Date.now() / 1000); |
| 141 | const enc = (v) => Buffer.from(JSON.stringify(v)).toString("base64url"); |
| 142 | const head = enc({ alg: "RS256", typ: "JWT" }); |
| 143 | const claims = enc({ iat: o.iat ?? now - 60, exp: o.exp ?? now + 540, iss }); |
| 144 | const sig = createSign("RSA-SHA256").update(`${head}.${claims}`).sign(pem).toString("base64url"); |
| 145 | return `${head}.${claims}.${sig}`; |
| 146 | } |
| 147 | |
| 148 | const octokit = (auth) => new Octokit({ auth, baseUrl: `${base}/api/v3`, request: { retries: 0 }, log: { debug() {}, info() {}, warn() {}, error() {} } }); |
| 149 | /** The status an Octokit call failed with (it throws for 4xx), and its message. */ |
| 150 | async function fails(p) { |
| 151 | try { |
| 152 | await p; |
| 153 | } catch (e) { |
| 154 | return { status: e.status, message: e.response?.data?.message ?? e.message }; |
| 155 | } |
| 156 | assert.fail("expected the request to fail"); |
| 157 | } |
| 158 | |
| 159 | const git = (args, cwd) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", HOME: work } }).toString(); |
| 160 | const gitFails = (args, cwd) => { |
| 161 | try { |
| 162 | git(args, cwd); |
| 163 | } catch (e) { |
| 164 | return `${e.stdout ?? ""}${e.stderr ?? ""}`; |
| 165 | } |
| 166 | assert.fail(`expected git ${args.join(" ")} to fail`); |
| 167 | }; |
| 168 | |
| 169 | const deliveries = (event, f = () => true) => received.filter((d) => d.event === event && f(d.json)); |
| 170 | const signed = (d, secret) => d.signature === `sha256=${createHmac("sha256", secret).update(d.body).digest("hex")}`; |
| 171 | |
| 172 | test("an app is registered, installed and used as a GitHub App would be", async () => { |
| 173 | // ada's repositories: site and docs are hers, web is her organization's. |
| 174 | for (const [owner, name] of [["ada", "site"], ["ada", "docs"], ["acme", "web"]]) { |
| 175 | await ok("POST", owner === "ada" ? "/user/repos" : `/orgs/${owner}/repos`, { name, private: true }, ada); |
| 176 | await ok("PUT", `/repos/${owner}/${name}/contents/README.md`, { message: "Start", content: Buffer.from(`# ${name}\n`).toString("base64") }, ada); |
| 177 | } |
| 178 | await ok("PUT", "/repos/ada/site/contents/.github/workflows/ci.yml", { message: "CI", content: Buffer.from("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n").toString("base64") }, ada); |
| 179 | |
| 180 | // ---------------------------------------------------------------- registering |
| 181 | const secret = "the-app-webhook-secret"; |
| 182 | const spec = { |
| 183 | name: "Cloud Builds", |
| 184 | description: "Builds and deploys on every push", |
| 185 | homepage_url: "https://builds.example.com", |
| 186 | setup_url: "https://builds.example.com/setup", |
| 187 | webhook_url: `http://127.0.0.1:${hooks.address().port}/webhooks`, |
| 188 | webhook_secret: secret, |
| 189 | default_permissions: { contents: "write", checks: "write", statuses: "write", pull_requests: "read" }, |
| 190 | default_events: ["push", "pull_request"], |
| 191 | }; |
| 192 | // Only a person in a browser registers apps: not bob for ada's organization, and no token. |
| 193 | assert.equal((await api("POST", "/diffinery/apps", { ...spec, owner: "acme" }, bob)).status, 422); |
| 194 | assert.equal((await api("POST", "/diffinery/apps", spec)).status, 401); |
| 195 | // An event the permissions don't cover, and a permission apps can't have. |
| 196 | assert.match((await api("POST", "/diffinery/apps", { ...spec, default_events: ["issues"] }, ada)).json.message, /issues permission/); |
| 197 | assert.match((await api("POST", "/diffinery/apps", { ...spec, default_permissions: { administration: "write" } }, ada)).json.message, /unsupported permission 'administration'/); |
| 198 | const app = await ok("POST", "/diffinery/apps", spec, ada, 201); |
| 199 | assert.equal(app.slug, "cloud-builds"); |
| 200 | assert.equal(typeof app.id, "number"); |
| 201 | assert.match(app.client_id, /^Iv23/); |
| 202 | // A PKCS#1 PEM file, as GitHub hands out: exactly what Node exports for the key in that format. |
| 203 | const key = createPrivateKey(app.pem); |
| 204 | assert.equal(key.asymmetricKeyDetails.modulusLength, 2048); |
| 205 | assert.equal(key.export({ type: "pkcs1", format: "pem" }), app.pem); |
| 206 | assert.deepEqual(app.permissions, { metadata: "read", contents: "write", pull_requests: "read", checks: "write", statuses: "write" }); |
| 207 | assert.equal(app.webhook_secret, null, "a chosen secret isn't echoed"); |
| 208 | assert.equal((await api("POST", "/diffinery/apps", { ...spec, name: "cloud builds" }, ada)).status, 422, "names are unique"); |
| 209 | |
| 210 | // ---------------------------------------------------------------- the app's JWT |
| 211 | const jwt = () => appJwt(app.pem, app.id); |
| 212 | const me = await octokit(jwt()).apps.getAuthenticated(); |
| 213 | assert.equal(me.data.slug, "cloud-builds"); |
| 214 | assert.equal(me.data.owner.login, "ada"); |
| 215 | // By client id too, as newer libraries do. |
| 216 | assert.equal((await api("GET", "/app", undefined, appJwt(app.pem, app.client_id))).json.id, app.id); |
| 217 | const stranger = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs1", format: "pem" }); |
| 218 | assert.equal((await api("GET", "/app", undefined, appJwt(stranger, app.id))).status, 401, "a key that isn't the app's"); |
| 219 | const now = Math.floor(Date.now() / 1000); |
| 220 | assert.match((await api("GET", "/app", undefined, appJwt(app.pem, app.id, { iat: now - 1200, exp: now - 600 }))).json.message, /Expiration time/); |
| 221 | assert.match((await api("GET", "/app", undefined, appJwt(app.pem, app.id, { iat: now, exp: now + 3600 }))).json.message, /too far in the future/); |
| 222 | assert.equal((await api("GET", "/app", undefined, appJwt(app.pem, 999999))).status, 401); |
| 223 | // A JWT is for app endpoints only: repositories don't open to it. |
| 224 | assert.equal((await api("GET", "/repos/ada/site", undefined, jwt())).status, 404); |
| 225 | assert.equal((await api("GET", "/user", undefined, jwt())).status, 403); |
| 226 | assert.deepEqual((await octokit(jwt()).apps.listInstallations()).data, []); |
| 227 | |
| 228 | // ---------------------------------------------------------------- installing |
| 229 | // A private app installs only on its owner's account; bob can't install it, or install on acme. |
| 230 | assert.equal((await api("POST", `/diffinery/apps/${app.slug}/installations`, { account: "bob" }, bob)).status, 404); |
| 231 | assert.equal((await api("POST", `/diffinery/apps/${app.slug}/installations`, { account: "acme" }, ada)).status, 403, "only on its owner's account while private"); |
| 232 | // Not someone else's repository, and not by a token. |
| 233 | assert.equal((await api("POST", `/diffinery/apps/${app.slug}/installations`, { repository_selection: "selected", repositories: ["acme/web"] }, ada)).status, 422); |
| 234 | const inst = await ok("POST", `/diffinery/apps/${app.slug}/installations`, { repository_selection: "selected", repositories: ["site"] }, ada, 201); |
| 235 | assert.equal(inst.account.login, "ada"); |
| 236 | assert.equal(inst.app_slug, "cloud-builds"); |
| 237 | assert.equal(inst.repository_selection, "selected"); |
| 238 | assert.equal((await api("POST", `/diffinery/apps/${app.slug}/installations`, {}, ada)).status, 422, "installed once per account"); |
| 239 | const created = await until("the installation webhook", () => deliveries("installation", (j) => j.action === "created")[0]); |
| 240 | assert.ok(signed(created, secret), "signed with the app's webhook secret"); |
| 241 | assert.equal(created.json.installation.id, inst.id); |
| 242 | assert.deepEqual(created.json.repositories.map((r) => r.full_name), ["ada/site"]); |
| 243 | assert.equal(created.json.sender.login, "ada"); |
| 244 | |
| 245 | const asApp = octokit(jwt()); |
| 246 | assert.equal((await asApp.apps.getRepoInstallation({ owner: "ada", repo: "site" })).data.id, inst.id); |
| 247 | assert.equal((await asApp.apps.getUserInstallation({ username: "ada" })).data.id, inst.id); |
| 248 | assert.equal((await fails(asApp.apps.getRepoInstallation({ owner: "ada", repo: "docs" }))).status, 404, "not selected"); |
| 249 | assert.equal((await fails(asApp.apps.getOrgInstallation({ org: "acme" }))).status, 404); |
| 250 | |
| 251 | // ---------------------------------------------------------------- installation tokens |
| 252 | assert.equal((await fails(asApp.apps.createInstallationAccessToken({ installation_id: inst.id, repositories: ["docs"] }))).status, 422, "a repository the installation doesn't cover"); |
| 253 | assert.match((await fails(asApp.apps.createInstallationAccessToken({ installation_id: inst.id, permissions: { issues: "write" } }))).message, /not granted to this installation/); |
| 254 | const t = (await asApp.apps.createInstallationAccessToken({ installation_id: inst.id })).data; |
| 255 | assert.match(t.token, /^dia_/); |
| 256 | assert.ok(Date.parse(t.expires_at) > Date.now() + 50 * 60_000); |
| 257 | assert.deepEqual(t.permissions, { metadata: "read", contents: "write", pull_requests: "read", checks: "write", statuses: "write" }); |
| 258 | const bot = octokit(t.token); |
| 259 | const repos = (await bot.apps.listReposAccessibleToInstallation()).data; |
| 260 | assert.deepEqual( |
| 261 | repos.repositories.map((r) => r.full_name), |
| 262 | ["ada/site"], |
| 263 | ); |
| 264 | const site = (await bot.repos.get({ owner: "ada", repo: "site" })).data; |
| 265 | assert.equal(site.full_name, "ada/site"); |
| 266 | assert.equal((await fails(bot.repos.get({ owner: "ada", repo: "docs" }))).status, 404, "a repository it wasn't given"); |
| 267 | assert.equal((await fails(bot.repos.get({ owner: "acme", repo: "web" }))).status, 404); |
| 268 | assert.equal((await api("GET", "/user", undefined, t.token)).status, 403, "an installation token isn't a person"); |
| 269 | assert.equal((await api("POST", "/diffinery/apps", spec, t.token)).status, 403, "nor can it register apps"); |
| 270 | |
| 271 | // What its permissions allow, endpoint by endpoint. |
| 272 | const head = (await bot.repos.getBranch({ owner: "ada", repo: "site", branch: "main" })).data.commit.sha; |
| 273 | const check = (await bot.checks.create({ owner: "ada", repo: "site", name: "Cloud Builds", head_sha: head, status: "completed", conclusion: "success", details_url: "https://builds.example.com/1" })).data; |
| 274 | assert.equal(check.app.slug, "cloud-builds"); |
| 275 | const status = (await bot.repos.createCommitStatus({ owner: "ada", repo: "site", sha: head, state: "success", context: "builds/deploy", target_url: "https://site.example.workers.dev" })).data; |
| 276 | assert.equal(status.creator.login, "cloud-builds[bot]"); |
| 277 | assert.equal(status.creator.type, "Bot"); |
| 278 | const issue = await fails(bot.issues.create({ owner: "ada", repo: "site", title: "no issues permission" })); |
| 279 | assert.equal(issue.status, 403); |
| 280 | assert.match(issue.message, /Resource not accessible by integration \(the app needs the issues permission: write\)/); |
| 281 | assert.equal((await fails(bot.repos.createWebhook({ owner: "ada", repo: "site", config: { url: "https://x.example.com" } }))).status, 403, "settings are never an app's"); |
| 282 | assert.equal((await fails(bot.actions.createOrUpdateRepoSecret({ owner: "ada", repo: "site", secret_name: "X", encrypted_value: "eA==" }))).status, 403); |
| 283 | const readme = (await bot.repos.getContent({ owner: "ada", repo: "site", path: "README.md" })).data; |
| 284 | await bot.repos.createOrUpdateFileContents({ owner: "ada", repo: "site", path: "README.md", sha: readme.sha, message: "Deployed", content: Buffer.from("# site\n\nDeployed.\n").toString("base64") }); |
| 285 | const wf = await fails(bot.repos.createOrUpdateFileContents({ owner: "ada", repo: "site", path: ".github/workflows/evil.yml", message: "sneak", content: Buffer.from("on: push\n").toString("base64") })); |
| 286 | assert.ok(wf.status === 403 || wf.status === 422, JSON.stringify(wf)); |
| 287 | assert.match(wf.message, /workflow/); |
| 288 | |
| 289 | // Narrowed to read: writes stop. |
| 290 | const ro = (await asApp.apps.createInstallationAccessToken({ installation_id: inst.id, permissions: { contents: "read" } })).data; |
| 291 | assert.deepEqual(ro.permissions, { metadata: "read", contents: "read" }); |
| 292 | assert.equal((await fails(octokit(ro.token).checks.create({ owner: "ada", repo: "site", name: "x", head_sha: head }))).status, 403); |
| 293 | |
| 294 | // ---------------------------------------------------------------- Git over HTTPS |
| 295 | const remote = (token, name = "site") => `${base.replace("://", `://x-access-token:${token}@`)}/ada/${name}.git`; |
| 296 | const clone = join(work, "site"); |
| 297 | git(["clone", "-q", remote(t.token), clone]); |
| 298 | git(["-c", "user.name=Cloud Builds", "-c", "user.email=builds@example.com", "commit", "-q", "--allow-empty", "-m", "From the app"], clone); |
| 299 | git(["push", "-q", "origin", "HEAD:main"], clone); |
| 300 | writeFileSync(join(clone, ".github/workflows/ci.yml"), "on: push\njobs: {}\n"); |
| 301 | git(["-c", "user.name=Cloud Builds", "-c", "user.email=builds@example.com", "commit", "-q", "-am", "Change CI"], clone); |
| 302 | assert.match(gitFails(["push", "-q", "origin", "HEAD:refs/heads/ci-change"], clone), /workflow/); |
| 303 | assert.match(gitFails(["ls-remote", remote(t.token, "docs")], work), /not found|404|Authentication|could not read/i); |
| 304 | assert.match(gitFails(["push", "-q", remote(ro.token), "HEAD~1:refs/heads/ro"], clone), /403|denied|not accessible|permission/i); |
| 305 | |
| 306 | // The push came back to the app as a webhook, through its installation. |
| 307 | const push = await until("the push webhook", () => deliveries("push", (j) => j.repository?.full_name === "ada/site")[0]); |
| 308 | assert.ok(signed(push, secret)); |
| 309 | assert.equal(push.json.installation.id, inst.id); |
| 310 | assert.equal(deliveries("push", (j) => j.repository?.full_name === "ada/docs").length, 0, "nothing from repositories it wasn't given"); |
| 311 | |
| 312 | // ---------------------------------------------------------------- changing repositories |
| 313 | await ok("PATCH", `/diffinery/installations/${inst.id}`, { repository_selection: "selected", repositories: ["site", "docs"] }, ada); |
| 314 | const added = await until("installation_repositories", () => deliveries("installation_repositories", (j) => j.action === "added")[0]); |
| 315 | assert.deepEqual(added.json.repositories_added.map((r) => r.full_name), ["ada/docs"]); |
| 316 | assert.equal((await bot.repos.get({ owner: "ada", repo: "docs" })).data.name, "docs", "a token for everything reaches the new repository"); |
| 317 | const docsId = (await bot.repos.get({ owner: "ada", repo: "docs" })).data.id; |
| 318 | const onlySite = (await asApp.apps.createInstallationAccessToken({ installation_id: inst.id, repositories: ["site"] })).data; |
| 319 | assert.deepEqual(onlySite.repositories.map((r) => r.name), ["site"]); |
| 320 | assert.equal((await fails(octokit(onlySite.token).repos.get({ owner: "ada", repo: "docs" }))).status, 404, "a token narrowed to site"); |
| 321 | assert.equal((await asApp.apps.createInstallationAccessToken({ installation_id: inst.id, repository_ids: [docsId] })).data.repositories[0].name, "docs", "by numeric id"); |
| 322 | // GitHub's per-repository endpoints for people. |
| 323 | assert.equal((await api("DELETE", `/user/installations/${inst.id}/repositories/${docsId}`, undefined, ada)).status, 204); |
| 324 | assert.equal((await fails(bot.repos.get({ owner: "ada", repo: "docs" }))).status, 404); |
| 325 | assert.equal((await api("PUT", `/user/installations/${inst.id}/repositories/${docsId}`, undefined, t.token)).status, 403, "an app can't widen its own access"); |
| 326 | const mine = await ok("GET", "/user/installations", undefined, ada); |
| 327 | assert.deepEqual(mine.installations.map((i) => i.app_slug), ["cloud-builds"]); |
| 328 | assert.equal((await ok("GET", "/user/installations", undefined, bob)).total_count, 0); |
| 329 | |
| 330 | // ---------------------------------------------------------------- new permissions wait for an admin |
| 331 | await ok("PATCH", `/diffinery/apps/${app.slug}`, { default_permissions: { ...spec.default_permissions, issues: "write" }, default_events: ["push", "pull_request", "issues"] }, ada); |
| 332 | const waiting = await ok("GET", `/diffinery/installations/${inst.id}`, undefined, ada); |
| 333 | assert.equal(waiting.pending_permissions.issues, "write"); |
| 334 | assert.equal(waiting.permissions.issues, undefined, "not until an admin accepts"); |
| 335 | assert.equal((await fails(octokit((await asApp.apps.createInstallationAccessToken({ installation_id: inst.id })).data.token).issues.create({ owner: "ada", repo: "site", title: "x" }))).status, 403); |
| 336 | await ok("POST", `/diffinery/installations/${inst.id}/accept_permissions`, undefined, ada); |
| 337 | await until("new_permissions_accepted", () => deliveries("installation", (j) => j.action === "new_permissions_accepted")[0]); |
| 338 | const withIssues = octokit((await asApp.apps.createInstallationAccessToken({ installation_id: inst.id })).data.token); |
| 339 | assert.equal((await withIssues.issues.create({ owner: "ada", repo: "site", title: "Deploy failed" })).data.user.login, "cloud-builds[bot]"); |
| 340 | // Less takes effect at once, even for tokens already made. |
| 341 | await ok("PATCH", `/diffinery/apps/${app.slug}`, { default_permissions: spec.default_permissions }, ada); |
| 342 | assert.equal((await fails(withIssues.issues.create({ owner: "ada", repo: "site", title: "x" }))).status, 403); |
| 343 | |
| 344 | // ---------------------------------------------------------------- suspending, revoking, uninstalling |
| 345 | await ok("PUT", `/diffinery/installations/${inst.id}/suspended`, undefined, ada, 204); |
| 346 | assert.equal((await api("GET", "/repos/ada/site", undefined, t.token)).status, 401, "a suspended installation's tokens stop"); |
| 347 | assert.equal((await fails(asApp.apps.createInstallationAccessToken({ installation_id: inst.id }))).status, 403); |
| 348 | assert.equal((await fails(asApp.apps.unsuspendInstallation({ installation_id: inst.id }))).status, 403, "the app can't lift an admin's suspension"); |
| 349 | await ok("DELETE", `/diffinery/installations/${inst.id}/suspended`, undefined, ada, 204); |
| 350 | await until("suspend and unsuspend webhooks", () => deliveries("installation", (j) => j.action === "unsuspend")[0] && deliveries("installation", (j) => j.action === "suspend")[0]); |
| 351 | const fresh = (await asApp.apps.createInstallationAccessToken({ installation_id: inst.id })).data.token; |
| 352 | assert.equal((await api("GET", "/repos/ada/site", undefined, fresh)).status, 200); |
| 353 | assert.equal((await api("DELETE", "/installation/token", undefined, fresh)).status, 204); |
| 354 | assert.equal((await api("GET", "/repos/ada/site", undefined, fresh)).status, 401, "revoked"); |
| 355 | |
| 356 | // Keys: a second one works, the last one can't go. |
| 357 | const key2 = await ok("POST", `/diffinery/apps/${app.slug}/keys`, undefined, ada, 201); |
| 358 | assert.equal((await api("GET", "/app", undefined, appJwt(key2.pem, app.id))).status, 200); |
| 359 | const keys = await ok("GET", `/diffinery/apps/${app.slug}/keys`, undefined, ada); |
| 360 | assert.equal(keys.length, 2); |
| 361 | await ok("DELETE", `/diffinery/apps/${app.slug}/keys/${keys[0].id}`, undefined, ada, 204); |
| 362 | assert.equal((await api("GET", "/app", undefined, jwt())).status, 401, "the deleted key's JWTs stop"); |
| 363 | assert.equal((await api("DELETE", `/diffinery/apps/${app.slug}/keys/${keys[1].id}`, undefined, ada)).status, 422); |
| 364 | |
| 365 | // The app uninstalls itself; then its owner deletes it. |
| 366 | const asApp2 = octokit(appJwt(key2.pem, app.id)); |
| 367 | await asApp2.apps.deleteInstallation({ installation_id: inst.id }); |
| 368 | await until("the deleted webhook", () => deliveries("installation", (j) => j.action === "deleted")[0]); |
| 369 | assert.equal((await api("GET", "/repos/ada/site", undefined, t.token)).status, 401, "an uninstalled app's tokens stop"); |
| 370 | assert.deepEqual((await asApp2.apps.listInstallations()).data, []); |
| 371 | assert.equal((await api("DELETE", `/diffinery/apps/${app.slug}`, undefined, bob)).status, 404); |
| 372 | await ok("DELETE", `/diffinery/apps/${app.slug}`, undefined, ada, 204); |
| 373 | assert.equal((await api("GET", "/app", undefined, appJwt(key2.pem, app.id))).status, 401); |
| 374 | }); |
| 375 | |
| 376 | test("people register, install and manage apps in the browser", async () => { |
| 377 | browser = await chromium.launch(); |
| 378 | const context = await browser.newContext({ baseURL: base }); |
| 379 | const page = await context.newPage(); |
| 380 | await page.goto("/healthz"); |
| 381 | await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA); |
| 382 | const shot = (name) => (process.env.E2E_SCREENSHOTS ? page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, `${name}.png`), fullPage: true }) : null); |
| 383 | |
| 384 | await page.goto("/settings/apps"); |
| 385 | await page.getByRole("link", { name: "New app" }).click(); |
| 386 | await page.getByLabel("Name", { exact: true }).fill("Preview Bot"); |
| 387 | await page.getByLabel("Homepage URL").fill("https://preview.example.com"); |
| 388 | await page.getByLabel("Description").fill("Posts a preview link on every pull request"); |
| 389 | // Permissions are Kumo selects: pick Read and write for Checks. |
| 390 | await page.getByRole("combobox", { name: "Checks" }).click(); |
| 391 | await page.getByRole("option", { name: "Read and write" }).click(); |
| 392 | await shot("apps-new"); |
| 393 | await page.getByRole("button", { name: "Register app" }).click(); |
| 394 | await page.getByText("Preview Bot is registered: its private key").waitFor(); |
| 395 | const pem = await page.locator("pre.secret").first().textContent(); |
| 396 | assert.equal(createPrivateKey(pem).asymmetricKeyDetails.modulusLength, 2048, "the key shown once is the app's private key"); |
| 397 | await shot("apps-created"); |
| 398 | const appId = Number(await page.locator(".field", { hasText: "App ID" }).locator(".mono").textContent()); |
| 399 | assert.equal((await api("GET", "/app", undefined, appJwt(pem, appId))).json.permissions.checks, "write"); |
| 400 | // Reloading never shows the key again. |
| 401 | await page.goto("/settings/apps/preview-bot"); |
| 402 | assert.equal(await page.locator("pre.secret").count(), 0); |
| 403 | |
| 404 | // Install it on ada's site repository only, from its public page. |
| 405 | await page.goto("/apps/preview-bot"); |
| 406 | await page.getByRole("link", { name: "Install" }).first().click(); |
| 407 | await page.getByRole("link", { name: "Install" }).first().click(); |
| 408 | await page.getByRole("radio", { name: /Only select repositories/ }).click(); |
| 409 | await page.getByRole("checkbox", { name: "site" }).click(); |
| 410 | await shot("apps-install"); |
| 411 | await page.getByRole("button", { name: "Install" }).click(); |
| 412 | await page.waitForURL(/\/settings\/installations\/\d+$/); |
| 413 | await page.getByText("Installed Preview Bot on ada.").waitFor(); |
| 414 | await shot("apps-installation"); |
| 415 | const inst = await ok("GET", "/user/installations", undefined, ada); |
| 416 | const mine = inst.installations.find((i) => i.app_slug === "preview-bot"); |
| 417 | assert.equal(mine.repository_selection, "selected"); |
| 418 | const token = (await octokit(appJwt(pem, appId)).apps.createInstallationAccessToken({ installation_id: mine.id })).data.token; |
| 419 | assert.deepEqual((await octokit(token).apps.listReposAccessibleToInstallation()).data.repositories.map((r) => r.name), ["site"]); |
| 420 | |
| 421 | // Suspend, unsuspend and uninstall from Installed apps. |
| 422 | await page.goto("/settings/installations"); |
| 423 | await page.getByRole("link", { name: "Preview Bot" }).click(); |
| 424 | await page.getByRole("button", { name: "Suspend" }).click(); |
| 425 | await page.getByText("Suspended Preview Bot.").waitFor(); |
| 426 | assert.equal((await api("GET", "/repos/ada/site", undefined, token)).status, 401); |
| 427 | await page.getByRole("button", { name: "Unsuspend" }).click(); |
| 428 | await page.getByText("Unsuspended Preview Bot.").waitFor(); |
| 429 | page.once("dialog", (d) => d.accept()); |
| 430 | await page.getByRole("button", { name: "Uninstall" }).click(); |
| 431 | await page.getByText("Uninstalled Preview Bot from ada.").waitFor(); |
| 432 | assert.equal((await ok("GET", "/user/installations", undefined, ada)).installations.filter((i) => i.app_slug === "preview-bot").length, 0); |
| 433 | |
| 434 | // Delete the app, typing its slug. |
| 435 | await page.goto("/settings/apps/preview-bot"); |
| 436 | await page.getByLabel("Confirm the app's slug").fill("preview-bot"); |
| 437 | await page.getByRole("button", { name: "Delete app" }).click(); |
| 438 | await page.getByText("Deleted Preview Bot.").waitFor(); |
| 439 | assert.equal((await api("GET", "/app", undefined, appJwt(pem, appId))).status, 401); |
| 440 | await context.close(); |
| 441 | }); |