Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

441 lines · 29.0 KBRaw History
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.
15import "./fresh-connections.mjs";
16import { spawn, execFileSync } from "node:child_process";
17import { createHash, createHmac, createPrivateKey, createSign, generateKeyPairSync, randomBytes } from "node:crypto";
18import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19import { createServer } from "node:http";
20import { tmpdir } from "node:os";
21import { join, dirname, resolve } from "node:path";
22import { fileURLToPath } from "node:url";
23import { test, before, after } from "node:test";
24import assert from "node:assert/strict";
25import { Octokit } from "@octokit/rest";
26import { chromium } from "playwright";
27import { migratedState } from "./d1-state.mjs";
28import { freePort } from "./ports.mjs";
29
30const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
31const work = mkdtempSync(join(tmpdir(), "diffinery-apps-"));
32let base;
33let dev;
34let devLog = "";
35let persist;
36let hooks;
37let browser;
38/** Webhook deliveries the app's server received: { event, delivery, signature, body, json }. */
39const received = [];
40
41const ADA = "ada-browser-session-0123456789abcdef";
42const BOB = "bob-browser-session-0123456789abcdef";
43const hash = (s) => createHash("sha256").update(s).digest("hex");
44const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
46async 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}
55const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
56const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
57
58before(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
104after(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. */
116async 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}
130const ada = { cookie: ADA };
131const bob = { cookie: BOB };
132async 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. */
139function 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
148const 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. */
150async 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
159const git = (args, cwd) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", HOME: work } }).toString();
160const 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
169const deliveries = (event, f = () => true) => received.filter((d) => d.event === event && f(d.json));
170const signed = (d, secret) => d.signature === `sha256=${createHmac("sha256", secret).update(d.body).digest("hex")}`;
171
172test("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
376test("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});