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.

4 files changed+801−780
Files (4)
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/accounts.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/accounts.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/security-limits.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs && node scripts/build-docs.mjs",
1515 "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
addedapps/diffinery/test/e2e/security-harness.mjs+233−0View file
@@ -1,0 +1,233 @@
1+// Shared by security.test.mjs and security-limits.test.mjs: a real `wrangler dev` Worker per test
2+// file, seeded with the same accounts, tokens and sessions, and the helpers both files use.
3+import { spawn, spawnSync, execFileSync } from "node:child_process";
4+import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
5+import { mkdtempSync, rmSync } from "node:fs";
6+import { tmpdir } from "node:os";
7+import { join, dirname, resolve } from "node:path";
8+import { fileURLToPath } from "node:url";
9+import { createServer } from "node:net";
10+import { before, after } from "node:test";
11+import assert from "node:assert/strict";
12+import { migratedState } from "./d1-state.mjs";
13+
14+export const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
15+export const work = mkdtempSync(join(tmpdir(), "diffinery-security-"));
16+export let base;
17+export let dev;
18+export let devLog = "";
19+
20+export const hash = (s) => createHash("sha256").update(s).digest("hex");
21+export const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
22+export const T = { sully: tok("sully"), olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
23+export const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` };
24+export const OWNER = "olive";
25+/** One minute of an environment's wait timer, in this Worker (WAIT_TIMER_MINUTE_MS). */
26+export const TIMER_MINUTE_MS = 10_000;
27+
28+export const freePort = () =>
29+ new Promise((res) => {
30+ const s = createServer();
31+ s.listen(0, "127.0.0.1", () => {
32+ const { port } = s.address();
33+ s.close(() => res(port));
34+ });
35+ });
36+
37+export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38+export async function until(what, fn, timeoutMs = 60_000) {
39+ const end = Date.now() + timeoutMs;
40+ while (Date.now() < end) {
41+ const v = await fn();
42+ if (v) return v;
43+ await sleep(300);
44+ }
45+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
46+}
47+
48+/** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */
49+export async function api(method, path, body, as = T.olive) {
50+ const headers = { "content-type": "application/json" };
51+ if (typeof as === "string") headers.authorization = `Bearer ${as}`;
52+ else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`;
53+ const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
54+ // wrangler dev closes idle keep-alive connections (the audit checks pause for seconds), so a reused one can be gone.
55+ const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e)));
56+ const text = await res.text();
57+ let json = null;
58+ try {
59+ json = text ? JSON.parse(text) : null;
60+ } catch {
61+ json = text;
62+ }
63+ return { status: res.status, json };
64+}
65+
66+/**
67+ * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
68+ * does (personal tokens can't claim jobs): one runner per repository and set of labels.
69+ */
70+export const e2eRunners = new Map();
71+export async function runnerClaim(repoPath, labels = ["linux"]) {
72+ const key = `${repoPath} ${labels.join(",")}`;
73+ if (!e2eRunners.has(key)) {
74+ const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
75+ assert.equal(reg.status, 201, JSON.stringify(reg.json));
76+ const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
77+ assert.equal(runner.status, 201, JSON.stringify(runner.json));
78+ e2eRunners.set(key, runner.json.token);
79+ }
80+ const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
81+ // A repository deleted and created again under the same name needs a new runner.
82+ if (r.status !== 401) return r;
83+ e2eRunners.delete(key);
84+ return runnerClaim(repoPath, labels);
85+}
86+export async function ok(method, path, body, as) {
87+ const r = await api(method, path, body, as);
88+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
89+ return r.json;
90+}
91+export async function status(expected, method, path, body, as) {
92+ const r = await api(method, path, body, as);
93+ assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
94+ return r.json;
95+}
96+export const b64 = (s) => Buffer.from(s).toString("base64");
97+
98+/**
99+ * Give someone access to a repository as GitHub does: someone new gets an invitation (201), which
100+ * they accept with their own token; an existing collaborator's role changes at once (204).
101+ */
102+export async function collaborator(repo, login, permission, invitee, as = T.olive) {
103+ const r = await api("PUT", `${repo}/collaborators/${login}`, { permission }, as);
104+ if (r.status === 204) return;
105+ assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`);
106+ await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee);
107+}
108+
109+before(async () => {
110+ const port = await freePort();
111+ base = `http://127.0.0.1:${port}`;
112+ const persist = join(work, "state");
113+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
114+ migratedState(APP, persist);
115+ const at = new Date().toISOString();
116+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
117+ const account = (id, login, kind, status, owner = null) => `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', '${status}', ${owner ? `'${owner}'` : "NULL"}, '${at}')`;
118+ const token = (id, account, t, scopes) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '${JSON.stringify(scopes)}', '${later}', '${at}')`;
119+ const session = (id, account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
120+ wrangler(
121+ "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
122+ [
123+ account("u-olive", "olive", "user", "active"),
124+ account("u-sully", "sully", "user", "active"),
125+ "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'",
126+ token("t-sully", "u-sully", T.sully, ["admin"]),
127+ account("a-bot", "olive-bot", "agent", "active", "u-olive"),
128+ account("u-rita", "rita", "user", "active"),
129+ account("u-penny", "penny", "user", "pending"),
130+ account("u-dora", "dora", "user", "disabled"),
131+ account("u-gone", "gone", "user", "disabled"),
132+ account("a-orphan", "gone-bot", "agent", "active", "u-gone"),
133+ token("t1", "u-olive", T.olive, ["admin"]),
134+ token("t2", "a-bot", T.bot, ["admin"]),
135+ token("t3", "u-rita", T.rita, ["write"]),
136+ token("t4", "u-dora", T.dora, ["write"]),
137+ token("t5", "a-orphan", T.orphan, ["write"]),
138+ account("o-guild", "guild", "org", "active"),
139+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`,
140+ token("t6", "u-olive", T.reader, ["read"]),
141+ token("t7", "u-rita", T.ritaAdmin, ["admin"]),
142+ account("u-milo", "milo", "user", "active"),
143+ token("t8", "u-milo", T.milo, ["admin"]),
144+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-milo', 'member', '${at}')`,
145+ session("s1", "u-penny", S.penny),
146+ session("s2", "u-dora", S.dora),
147+ session("s3", "u-olive", S.olive),
148+ account("o-shop", "shop", "org", "active"),
149+ account("u-sam", "sam", "user", "active"),
150+ token("t9", "u-sam", T.sam, ["admin"]),
151+ account("a-helper", "olive-helper", "agent", "active", "u-olive"),
152+ token("t10", "a-helper", T.helper, ["write"]),
153+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
154+ session("s4", "u-sam", S.sam),
155+ "UPDATE accounts SET email = 'sam@example.com' WHERE id = 'u-sam'",
156+ // olive's other browsers: one signed in 3 hours ago, one 100 days ago (used every day since), one spare.
157+ session("s5", "u-olive", S.oliveOld).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 3 * 3600_000).toISOString()}', '${at}'`),
158+ session("s6", "u-olive", S.oliveStale).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 100 * 86400_000).toISOString()}', '${at}'`),
159+ session("s7", "u-olive", S.oliveSpare),
160+ ].join("; "),
161+ );
162+ dev = spawn(
163+ "pnpm",
164+ ["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", `WAIT_TIMER_MINUTE_MS:${TIMER_MINUTE_MS}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"],
165+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
166+ );
167+ dev.stdout.on("data", (d) => (devLog += d));
168+ dev.stderr.on("data", (d) => (devLog += d));
169+ await until(
170+ "wrangler dev",
171+ async () => {
172+ try {
173+ return (await fetch(`${base}/healthz`)).ok;
174+ } catch {
175+ return false;
176+ }
177+ },
178+ 120_000,
179+ );
180+});
181+
182+after(() => {
183+ if (dev?.pid) {
184+ try {
185+ process.kill(-dev.pid, "SIGTERM");
186+ } catch {}
187+ }
188+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
189+});
190+
191+/** A private repository owned by olive with one commit on main. */
192+export async function repoWithReadme(name, extra = {}) {
193+ await ok("POST", "/user/repos", { name, ...extra });
194+ const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) });
195+ return put.commit.sha;
196+}
197+
198+/** Audit entries recorded so far, read straight from the local D1 database. The running dev server can hold its lock, so a busy database is retried. */
199+export function auditLog(action) {
200+ for (let attempt = 1; ; attempt++) {
201+ try {
202+ return readAuditLog(action);
203+ } catch (e) {
204+ if (attempt >= 10 || !/SQLITE_BUSY|database is locked/.test(String(e.stderr ?? e.message))) throw e;
205+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300 * attempt);
206+ }
207+ }
208+}
209+
210+export function readAuditLog(action) {
211+ const out = execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", join(work, "state"), "--json", "--command", `SELECT actor_login, action, target, detail FROM audit_log WHERE action = '${action}'`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
212+ return JSON.parse(out.toString())[0].results;
213+}
214+
215+export const oliveSubmits = (page, action, fields) => submitsAs(S.olive, page, action, fields);
216+
217+/** A web form post signed in with `session`, with the CSRF pair taken from `page`. */
218+export async function submitsAs(session, page, action, fields) {
219+ const cookie = `__Host-dfy_session=${session}`;
220+ const res = await fetch(`${base}${page}`, { headers: { cookie } });
221+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
222+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
223+ assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`);
224+ const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } });
225+ return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() };
226+}
227+
228+/** Runs git, returning its stderr; fails the test when git fails. */
229+export function gitRun(args, cwd) {
230+ const r = spawnSync("git", ["-c", "protocol.version=2", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd, encoding: "utf8" });
231+ assert.equal(r.status, 0, `git ${args.join(" ")} failed:\n${r.stderr}`);
232+ return r.stderr;
233+}
addedapps/diffinery/test/e2e/security-limits.test.mjs+559−0View file
@@ -1,0 +1,559 @@
1+// End-to-end security checks, continued from security.test.mjs (same seeded accounts, in a
2+// `wrangler dev` of this file's own, so the two files run side by side): environment reviewers,
3+// invitations by username, per-account quotas, kill switches and the Markdown image proxy.
4+//
5+// pnpm --filter @diffinery/app e2e
6+import { spawnSync, execFileSync } from "node:child_process";
7+import { createHash, randomBytes } from "node:crypto";
8+import { writeFileSync } from "node:fs";
9+import { join } from "node:path";
10+import { createServer as createHttpServer } from "node:http";
11+import { test } from "node:test";
12+import assert from "node:assert/strict";
13+import { work, base, T, S, OWNER, TIMER_MINUTE_MS, sleep, until, api, runnerClaim, ok, status, b64, collaborator, repoWithReadme, auditLog, oliveSubmits, submitsAs, gitRun } from "./security-harness.mjs";
14+
15+test("security: environment reviewers hold a deploy job until a listed person approves, and nobody else can", async () => {
16+ const R = `/repos/${OWNER}/gated-deploy`;
17+ await repoWithReadme("gated-deploy");
18+ await collaborator(R, "rita", "read", T.rita);
19+ await collaborator(R, "sam", "read", T.sam);
20+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
21+ const ritaId = (await ok("GET", "/user", undefined, T.rita)).id;
22+
23+ // Only people with access can be reviewers, never an agent or a team; limits are GitHub's; agents can't set them.
24+ await ok("PUT", `${R}/environments/production`, {});
25+ await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "olive-bot" }] });
26+ await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "Team", id: 1 }] });
27+ await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "milo" }] });
28+ await status(422, "PUT", `${R}/environments/production`, { wait_timer: 43_201 });
29+ await status(403, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "rita" }] }, T.bot);
30+ await status(201, "PUT", `${R}/environments/production/secrets/DEPLOY_KEY`, { value: "deploy-value" });
31+ const env = await ok("PUT", `${R}/environments/production`, { reviewers: [{ type: "User", id: ritaId }, { type: "User", login: "sam" }, { type: "User", login: "olive" }], prevent_self_review: true, deployment_branch_policy: null });
32+ assert.equal(env.can_admins_bypass, false);
33+ const rule = env.protection_rules.find((r) => r.type === "required_reviewers");
34+ assert.deepEqual(rule.reviewers.map((r) => r.reviewer.login).sort(), ["olive", "rita", "sam"]);
35+ assert.equal(rule.prevent_self_review, true);
36+
37+ const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`;
38+ const claim = async () => {
39+ const r = await runnerClaim(R, ["linux"]);
40+ if (r.status !== 200) return null;
41+ const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
42+ await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
43+ return spec;
44+ };
45+ const waitingRun = async (sha) => {
46+ const run = await until("a run waiting for review", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs.find((r) => r.head_sha === sha && r.status === "waiting"));
47+ const jobs = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs;
48+ assert.equal(jobs.length, 1);
49+ assert.equal(jobs[0].status, "waiting");
50+ // Held before anything starts: no runner can claim it, so no token or secret has left.
51+ assert.equal(await claim(), null);
52+ return run;
53+ };
54+
55+ // olive pushes: the job waits, and the deployment says so.
56+ const first = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy", content: b64(wf(1)) })).commit.sha;
57+ const run = await waitingRun(first);
58+ const pendingAs = async (as, id = run.id) => (await ok("GET", `${R}/actions/runs/${id}/pending_deployments`, undefined, as))[0];
59+ const p = await pendingAs(T.rita);
60+ assert.equal(p.environment.name, "production");
61+ assert.equal(p.current_user_can_approve, true);
62+ assert.equal((await pendingAs(T.olive)).current_user_can_approve, false, "olive started the run and the environment prevents self-review");
63+ const deployment = (await ok("GET", `${R}/deployments?environment=production`))[0];
64+ assert.equal((await ok("GET", `${R}/deployments/${deployment.id}/statuses`))[0].state, "waiting");
65+
66+ // Nobody but a listed person, other than the run's starter, with a token that may write, decides.
67+ const body = { environment_ids: [p.environment.id], state: "approved", comment: "ship it" };
68+ assert.match((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body)).json.message, /doesn't let whoever started a run approve it/);
69+ assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.bot)).status, 403);
70+ assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.reader)).status, 403);
71+ assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.milo)).status, 404);
72+ await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, environment_ids: [999] }, T.rita);
73+ const approved = await ok("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.rita);
74+ assert.equal(approved[0].environment, "production");
75+ // One decision counts: a second one finds nothing waiting.
76+ await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, state: "rejected" }, T.sam);
77+
78+ // Now the job starts, and only now gets the environment's secret.
79+ const spec = await until("the approved job", claim);
80+ assert.equal(spec.secrets.DEPLOY_KEY, "deploy-value");
81+ const done = await until("the run done", async () => {
82+ const r = await ok("GET", `${R}/actions/runs/${run.id}`);
83+ return r.status === "completed" ? r : null;
84+ });
85+ assert.equal(done.conclusion, "success");
86+ const approvals = await ok("GET", `${R}/actions/runs/${run.id}/approvals`);
87+ assert.deepEqual(approvals.map((a) => [a.state, a.user.login, a.comment, a.environments[0].name]), [["approved", "rita", "ship it", "production"]]);
88+ assert.deepEqual((await ok("GET", `${R}/deployments/${deployment.id}/statuses`)).map((s) => s.state), ["success", "in_progress", "waiting"]);
89+
90+ // A rejection fails the job without starting it. sam decides in the browser, from the run page.
91+ const file = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
92+ const second = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 2", content: b64(wf(2)), sha: file.sha })).commit.sha;
93+ const run2 = await waitingRun(second);
94+ const page = await (await fetch(`${base}/${OWNER}/gated-deploy/actions/runs/${run2.id}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text();
95+ assert.match(page, /Waiting for review/);
96+ assert.match(page, /You started this run, and this environment doesn&#39;t let you approve it|You started this run, and this environment doesn't let you approve it/);
97+ const envId = (await pendingAs(T.rita, run2.id)).environment.id;
98+ const rejected = await submitsAs(S.sam, `/${OWNER}/gated-deploy/actions/runs/${run2.id}`, `/${OWNER}/gated-deploy/actions/runs/${run2.id}/review`, { environment: String(envId), comment: "not today", state: "rejected" });
99+ assert.equal(rejected.status, 303);
100+ assert.match(rejected.flash, /Rejected/);
101+ const failed = await until("the rejected run done", async () => {
102+ const r = await ok("GET", `${R}/actions/runs/${run2.id}`);
103+ return r.status === "completed" ? r : null;
104+ });
105+ assert.equal(failed.conclusion, "failure");
106+ const job = (await ok("GET", `${R}/actions/runs/${run2.id}/jobs`)).jobs[0];
107+ assert.match(job.failure.message, /rejected by sam: not today/);
108+ assert.equal(await claim(), null);
109+
110+ // Self-review compares accounts, not names: sam starts a run, renames, and still can't approve it.
111+ await collaborator(R, "sam", "write", T.sam);
112+ const file3 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
113+ const fourth = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 4", content: b64(wf(4)), sha: file3.sha }, T.sam)).commit.sha;
114+ const run4 = await waitingRun(fourth);
115+ const env4 = (await pendingAs(T.rita, run4.id)).environment.id;
116+ const renamed = await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam-renamed", name: "sam" });
117+ assert.equal(renamed.status, 303);
118+ try {
119+ assert.equal((await ok("GET", "/user", undefined, T.sam)).login, "sam-renamed");
120+ assert.equal((await pendingAs(T.sam, run4.id)).current_user_can_approve, false);
121+ const self = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.sam);
122+ assert.equal(self.status, 403);
123+ assert.match(self.json.message, /doesn't let whoever started a run approve it/);
124+ } finally {
125+ assert.equal((await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam", name: "sam" })).status, 303);
126+ }
127+
128+ // Access counts when deciding, not only when the list was set: on a public repository, where
129+ // anyone may read, a reviewer removed from it no longer approves.
130+ await ok("PATCH", R, { visibility: "public" });
131+ await status(204, "DELETE", `${R}/collaborators/rita`);
132+ assert.equal((await pendingAs(T.rita, run4.id)).current_user_can_approve, false);
133+ const removed = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.rita);
134+ assert.equal(removed.status, 403);
135+ assert.match(removed.json.message, /only people with access to this repository/);
136+ await ok("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "rejected", comment: "cleanup" });
137+ await ok("PATCH", R, { visibility: "private" });
138+ await collaborator(R, "rita", "read", T.rita);
139+
140+ // A wait timer alone holds the job until it passes; nobody skips it.
141+ await ok("PUT", `${R}/environments/production`, { reviewers: [], wait_timer: 1 });
142+ const file2 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
143+ const third = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 3", content: b64(wf(3)), sha: file2.sha })).commit.sha;
144+ const run3 = await waitingRun(third);
145+ const timer = await pendingAs(T.rita, run3.id);
146+ assert.equal(timer.wait_timer, 1);
147+ assert.equal(timer.current_user_can_approve, false);
148+ await status(422, "POST", `${R}/actions/runs/${run3.id}/pending_deployments`, { environment_ids: [timer.environment.id], state: "approved" }, T.rita);
149+ // The Worker runs with a short wait-timer minute (WAIT_TIMER_MINUTE_MS), so the timer passes in seconds.
150+ if (Date.now() < Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 2000) assert.equal(await claim(), null, "nothing starts before the timer passes");
151+ const timed = await until("the job after its wait timer", claim, 60_000);
152+ assert.ok(Date.now() >= Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 1000, "the job waited for its timer");
153+ assert.equal(timed.secrets.DEPLOY_KEY, "deploy-value");
154+});
155+
156+test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => {
157+ // ------------------------------------------------------------ a repository: milo is invited, not added
158+ await repoWithReadme("by-name");
159+ const R = `/repos/${OWNER}/by-name`;
160+ const inv = await status(201, "PUT", `${R}/collaborators/milo`, { permission: "write" });
161+ assert.equal(inv.invitee.login, "milo");
162+ assert.equal(inv.permissions, "write");
163+ await status(404, "GET", R, undefined, T.milo);
164+ // The admin's list names the username, never the person's email address.
165+ const listed = await ok("GET", `${R}/invitations`);
166+ assert.deepEqual(listed.map((i) => [i.login, i.email, i.role]), [["milo", null, "write"]]);
167+ // In-app and in milo's own list.
168+ assert.ok((await ok("GET", "/notifications", undefined, T.milo)).some((n) => /olive invited you to olive\/by-name/.test(n.title)));
169+ const mine = await ok("GET", "/user/repository_invitations", undefined, T.milo);
170+ assert.deepEqual(mine.map((i) => [i.id, i.repository.full_name]), [[inv.id, `${OWNER}/by-name`]]);
171+ // Nobody else can accept it, and a read token can't either.
172+ await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.rita);
173+ assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
174+ await status(404, "GET", R, undefined, T.rita);
175+ // Asking again changes the pending invitation's role rather than sending another.
176+ assert.equal((await status(201, "PUT", `${R}/collaborators/milo`, { permission: "admin" })).id, inv.id);
177+ // milo accepts and has the role offered.
178+ await status(204, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
179+ assert.equal((await ok("GET", R, undefined, T.milo)).permissions.admin, true);
180+ await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
181+ // A collaborator's role then changes at once.
182+ await status(204, "PUT", `${R}/collaborators/milo`, { permission: "read" });
183+ assert.equal((await ok("GET", R, undefined, T.milo)).permissions.push, false);
184+ // The settings form that sets a role invites someone new the same way.
185+ const viaForm = await oliveSubmits(`/${OWNER}/by-name/settings/access`, `/${OWNER}/by-name/settings/access`, { login: "rita", role: "write" });
186+ assert.equal(viaForm.status, 303);
187+ assert.match(viaForm.flash, /Invited rita/);
188+ await status(404, "GET", R, undefined, T.rita);
189+ // rita declines: no access, and the invitation is gone.
190+ const ritas = await ok("GET", "/user/repository_invitations", undefined, T.rita);
191+ assert.equal(ritas.length, 1);
192+ await status(204, "DELETE", `/user/repository_invitations/${ritas[0].id}`, undefined, T.rita);
193+ await status(404, "GET", R, undefined, T.rita);
194+ assert.deepEqual(await ok("GET", `${R}/invitations`), []);
195+ // Agents are still added directly: they act for a person who already has access.
196+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
197+
198+ // ------------------------------------------------------------ an organization: Add people and the role form both invite
199+ await ok("POST", "/user/orgs", { login: "by-name-org" });
200+ const members = async () => (await ok("GET", "/orgs/by-name-org/members")).map((u) => u.login);
201+ const added = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/people", { who: "milo", role: "member" });
202+ assert.equal(added.status, 303);
203+ assert.match(added.flash, /Invited milo to by-name-org/);
204+ const roleForm = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/members", { login: "sam", role: "admin" });
205+ assert.match(roleForm.flash, /Invited sam to by-name-org/);
206+ assert.deepEqual(await members(), ["olive"]);
207+ assert.equal((await ok("GET", "/user/memberships/orgs/by-name-org", undefined, T.milo)).state, "pending");
208+ assert.deepEqual((await ok("GET", "/user/memberships/orgs?state=pending", undefined, T.milo)).map((m) => m.organization.login), ["by-name-org"]);
209+ // milo accepts through the API, sam declines on the web.
210+ assert.equal((await ok("PATCH", "/user/memberships/orgs/by-name-org", { state: "active" }, T.milo)).state, "active");
211+ const samInvite = (await ok("GET", "/orgs/by-name-org/invitations")).find((i) => i.login === "sam");
212+ const page = await (await fetch(`${base}/settings/invitations`, { headers: { cookie: `__Host-dfy_session=${S.sam}` } })).text();
213+ assert.match(page, /by-name-org/);
214+ const declined = await submitsAs(S.sam, "/settings/invitations", `/settings/invitations/${samInvite.id}/decline`, {});
215+ assert.match(declined.flash, /Invitation declined/);
216+ assert.deepEqual((await members()).sort(), ["milo", "olive"]);
217+ await status(404, "GET", "/user/memberships/orgs/by-name-org", undefined, T.sam);
218+
219+ // ------------------------------------------------------------ someone waiting for an invite accepts it from the waiting page
220+ await repoWithReadme("for-penny");
221+ const pennyInvite = await status(201, "PUT", `/repos/${OWNER}/for-penny/collaborators/penny`, { permission: "read" });
222+ const waiting = await (await fetch(base, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).text();
223+ assert.match(waiting, /Waiting for an invite/);
224+ assert.match(waiting, new RegExp(`${OWNER}/for-penny`));
225+ const accepted = await submitsAs(S.penny, "/", `/settings/invitations/${pennyInvite.id}/accept`, {});
226+ assert.equal(accepted.status, 303, accepted.flash);
227+ assert.equal(accepted.location, `/${OWNER}/for-penny`);
228+ assert.equal((await fetch(`${base}/${OWNER}/for-penny`, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).status, 200);
229+
230+ // ------------------------------------------------------------ a repository with invitations can still be deleted
231+ await repoWithReadme("invited-then-deleted");
232+ await status(201, "PUT", `/repos/${OWNER}/invited-then-deleted/collaborators/rita`, { permission: "read" });
233+ await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`);
234+ assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
235+});
236+
237+test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => {
238+ // Only site administrators see or change another account's quotas.
239+ await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 });
240+ await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita);
241+ await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully);
242+ const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully);
243+ assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]);
244+ await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully"));
245+ // rita sees her own usage and limits.
246+ const mine = await ok("GET", "/user/usage", undefined, T.rita);
247+ assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]);
248+
249+ // ------------------------------------------------------------ repositories
250+ const owned = mine.repositories.count;
251+ await setRitaRepos(owned + 1);
252+ await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita);
253+ assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/);
254+
255+ // ------------------------------------------------------------ hosted minutes: a hosted job isn't started
256+ const R = "/repos/rita/rita-quota-1";
257+ await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita);
258+ const job = await until("the job to fail", async () => {
259+ const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
260+ if (!runs.length) return null;
261+ const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
262+ return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null;
263+ });
264+ assert.equal(job.job.conclusion, "failure");
265+ const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text();
266+ assert.match(page, /used its 0 hosted minutes for this month/);
267+
268+ // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit
269+ assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/);
270+ await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully);
271+ assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/);
272+
273+ // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room
274+ const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita);
275+ await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita);
276+ const held = await until("a second run", async () => {
277+ const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
278+ if (runs.length < 2) return null;
279+ const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
280+ return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null;
281+ });
282+ await sleep(3000);
283+ assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued");
284+ // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev).
285+ await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully);
286+ await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000);
287+});
288+
289+test("security: a per-account storage quota refuses pushes, Git LFS and release asset uploads past it; reads and deletes keep working", async () => {
290+ const R = "/repos/milo/hoard";
291+ await ok("POST", "/user/repos", { name: "hoard" }, T.milo);
292+ const usage = async () => (await ok("GET", "/user/usage", undefined, T.milo)).storage_bytes;
293+ assert.equal((await usage()).limit, 10 * 1024 ** 3);
294+ // Push main and a topic branch, and upload a release asset, while there is room.
295+ const clone = join(work, "hoard");
296+ const remote = `${base.replace("://", "://x:" + T.milo + "@")}/milo/hoard.git`;
297+ execFileSync("git", ["init", "-q", "-b", "main", clone]);
298+ const commit = (name) => {
299+ writeFileSync(join(clone, name), randomBytes(2000).toString("hex"));
300+ gitRun(["add", name], clone);
301+ gitRun(["commit", "-q", "-m", name], clone);
302+ };
303+ const push = (...args) => spawnSync("git", ["-c", "protocol.version=2", "push", remote, ...args], { cwd: clone, encoding: "utf8" });
304+ commit("a.txt");
305+ assert.equal(push("HEAD:main", "HEAD:refs/heads/topic").status, 0);
306+ // Repositories report what they store as it changes: wait until the count stops moving.
307+ const settled = async (what, test) => {
308+ let last = -1;
309+ return until(what, async () => {
310+ const u = (await usage()).used;
311+ if (u === last && test(u)) return u;
312+ last = u;
313+ await sleep(1000);
314+ return null;
315+ });
316+ };
317+ const pushed = await settled("the push to be counted", (u) => u > 2000);
318+ const rel = await ok("POST", `${R}/releases`, { tag_name: "v1", target_commitish: "main" }, T.milo);
319+ const upload = (bytes) => fetch(`${base}/api/uploads/repos/milo/hoard/releases/${rel.id}/assets?name=${bytes.length}.bin`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: bytes });
320+ const asset = await (await upload(randomBytes(3000))).json();
321+ assert.equal(asset.size, 3000);
322+ const used = await settled("the asset to be counted", (u) => u === pushed + 3000);
323+ assert.equal((await ok("GET", "/admin/accounts/milo/quotas", undefined, T.sully)).storage_bytes.used, used);
324+
325+ // ------------------------------------------------------------ at the limit: nothing new is stored
326+ const set = await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: used }, T.sully);
327+ assert.deepEqual(set.storage_bytes, { used, limit: used });
328+ const full = new RegExp(`milo has reached its storage limit of ${used} bytes`);
329+ commit("b.txt");
330+ const refused = push("HEAD:main");
331+ assert.notEqual(refused.status, 0);
332+ assert.match(refused.stderr, new RegExp(`remote: Push refused: ${full.source}`));
333+ assert.match(refused.stderr, /storage limit reached/);
334+ const lfsOid = (bytes) => createHash("sha256").update(bytes).digest("hex");
335+ const lfsBlob = randomBytes(1000);
336+ const batch = async () => {
337+ const r = await fetch(`${base}/milo/hoard.git/info/lfs/objects/batch`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, accept: "application/vnd.git-lfs+json", "content-type": "application/vnd.git-lfs+json" }, body: JSON.stringify({ operation: "upload", objects: [{ oid: lfsOid(lfsBlob), size: lfsBlob.length }] }) });
338+ assert.equal(r.status, 200);
339+ return (await r.json()).objects[0];
340+ };
341+ const lfs = await batch();
342+ assert.equal(lfs.error.code, 507);
343+ assert.match(lfs.error.message, full);
344+ const put = await fetch(`${base}/milo/hoard.git/info/lfs/objects/${lfsOid(lfsBlob)}`, { method: "PUT", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: lfsBlob });
345+ assert.equal(put.status, 507);
346+ const late = await upload(randomBytes(10));
347+ assert.equal(late.status, 403);
348+ assert.match((await late.json()).message, full);
349+ assert.match((await status(403, "PUT", `${R}/contents/c.txt`, { message: "c", content: b64("more") }, T.milo)).message, full);
350+ assert.match((await status(403, "POST", `${R}/git/blobs`, { content: "more" }, T.milo)).message, full);
351+ // A chunked registry upload declares no length, so its bytes are counted as they arrive.
352+ const reg = { authorization: `Basic ${Buffer.from(`milo:${T.milo}`).toString("base64")}` };
353+ const started = await fetch(`${base}/v2/milo/hoard/blobs/uploads/`, { method: "POST", headers: reg });
354+ assert.equal(started.status, 202, await started.clone().text());
355+ const chunked = new ReadableStream({
356+ start(ctl) {
357+ ctl.enqueue(randomBytes(1000));
358+ ctl.close();
359+ },
360+ });
361+ const sneak = await fetch(`${base}${started.headers.get("location")}`, { method: "PATCH", headers: { ...reg, "content-type": "application/octet-stream" }, body: chunked, duplex: "half" });
362+ assert.equal(sneak.status, 403);
363+ assert.match(await sneak.text(), /milo has reached its storage limit/);
364+ // An upload refused at the quota can't finish, so it is gone with its bytes: a retry has to start again.
365+ assert.equal((await fetch(`${base}${started.headers.get("location")}`, { headers: reg })).status, 404);
366+
367+ // ------------------------------------------------------------ reads, ref deletions and ref moves still work
368+ assert.equal((await ok("GET", `${R}/contents/a.txt`, undefined, T.milo)).size, 4000);
369+ assert.equal(push(":refs/heads/topic").status, 0);
370+ assert.equal(push("HEAD~1:refs/heads/copy").status, 0);
371+ assert.deepEqual((await ok("GET", `${R}/branches`, undefined, T.milo)).map((b) => b.name).sort(), ["copy", "main"]);
372+
373+ // ------------------------------------------------------------ deleting brings usage down, and there is room again
374+ await status(204, "DELETE", `${R}/releases/assets/${asset.id}`, undefined, T.milo);
375+ await until("the asset's bytes to be given back", async () => (await usage()).used === used - 3000);
376+ assert.ok((await batch()).actions.upload);
377+ await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: null }, T.sully);
378+ assert.equal(push("HEAD:main").status, 0);
379+});
380+
381+/** Set rita's repository quota (site administrators only). */
382+async function setRitaRepos(n) {
383+ await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully);
384+}
385+
386+test("security: kill switches suspend Actions for a repository or an account, and pause hosted compute on the instance", async () => {
387+ const R = `/repos/${OWNER}/halted`;
388+ const S = `/admin/repos/${OWNER}/halted/actions-suspension`;
389+ const A = `/admin/accounts/${OWNER}/actions-suspension`;
390+ const P = "/admin/hosted-compute-pause";
391+ await repoWithReadme("halted");
392+ const runs = async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs;
393+ const html = async (path, as = T.olive) => (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${as}` } })).text();
394+ const push = async (path, text) => ok("PUT", `${R}/contents/${path}`, { message: path, content: b64(text) });
395+ // A self-hosted job that no runner takes yet: the run stays in progress.
396+ await push(".github/workflows/ci.yml", "name: ci\non:\n push:\n workflow_dispatch:\n repository_dispatch:\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n");
397+ const first = await until("a run waiting for a runner", async () => {
398+ const [run] = await runs();
399+ return run && (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs.length ? run : null;
400+ });
401+
402+ try {
403+ // ------------------------------------------------------------ only site administrators
404+ for (const path of [S, A, P]) {
405+ for (const method of ["GET", "PUT", "DELETE"]) assert.match((await status(403, method, path, method === "PUT" ? { reason: "mine" } : undefined, T.olive)).message, /only site administrators/);
406+ await status(401, "PUT", path, { reason: "no token" }, null);
407+ }
408+ await status(422, "PUT", S, {}, T.sully);
409+ await status(404, "PUT", `/admin/repos/${OWNER}/nowhere/actions-suspension`, { reason: "x" }, T.sully);
410+ await status(404, "PUT", "/admin/accounts/olive-bot/actions-suspension", { reason: "x" }, T.sully);
411+ assert.equal((await ok("GET", S, undefined, T.sully)).suspended, false);
412+
413+ // ------------------------------------------------------------ a repository: what runs stops, nothing new starts
414+ const s = await ok("PUT", S, { reason: "crypto mining" }, T.sully);
415+ assert.deepEqual([s.suspended, s.reason, s.suspended_by, s.stopped.runs, s.stopped.jobs], [true, "crypto mining", "sully", 1, 1]);
416+ const cancelled = await until("the run to be cancelled", async () => {
417+ const r = await ok("GET", `${R}/actions/runs/${first.id}`);
418+ return r.status === "completed" ? r : null;
419+ });
420+ assert.equal(cancelled.conclusion, "cancelled");
421+ assert.match(await html(`/${OWNER}/halted/actions/runs/${first.id}`), /Cancelled: Actions are suspended for this repository by a site administrator: crypto mining/);
422+ assert.equal((await runnerClaim(R, ["linux"])).status, 204);
423+ assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for this repository by a site administrator: crypto mining/);
424+ assert.match((await status(403, "POST", `${R}/dispatches`, { event_type: "go" })).message, /suspended for this repository/);
425+ assert.match((await status(403, "POST", `${R}/actions/runs/${first.id}/rerun`)).message, /suspended for this repository/);
426+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /suspended for this repository/);
427+ const before = (await runs()).length;
428+ await push("one.txt", "one\n");
429+ await sleep(3000);
430+ assert.equal((await runs()).length, before, "a push starts no run while Actions are suspended");
431+ for (const path of [`/${OWNER}/halted/actions`, `/${OWNER}/halted/settings`]) assert.match(await html(path), /Actions are suspended for this repository by a site administrator/, path);
432+ assert.match(await html("/settings/admin/kill-switches", T.sully), new RegExp(`${OWNER}/halted`));
433+ await until("the suspension's audit entry", async () => auditLog("actions.suspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully" && JSON.parse(e.detail).reason === "crypto mining"));
434+
435+ // Lifting it lets new work start; nothing restarts.
436+ assert.equal((await ok("DELETE", S, undefined, T.sully)).suspended, false);
437+ assert.equal((await ok("GET", `${R}/actions/runs/${first.id}`)).conclusion, "cancelled");
438+ await push("two.txt", "two\n");
439+ await until("a new run", async () => (await runs()).length > before);
440+ await ok("POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" });
441+ const sandbox = await api("POST", `${R}/diffinery/sandboxes`, {});
442+ assert.notEqual(sandbox.status, 403, JSON.stringify(sandbox.json)); // 503 here: no sandbox pool under this test
443+ await until("the lift's audit entry", async () => auditLog("actions.unsuspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully"));
444+
445+ // ------------------------------------------------------------ an account: every repository it owns
446+ const a = await ok("PUT", A, { reason: "abuse report" }, T.sully);
447+ assert.deepEqual([a.account, a.suspended, a.reason], [OWNER, true, "abuse report"]);
448+ assert.ok(a.stopped.runs >= 1, JSON.stringify(a.stopped));
449+ assert.equal((await ok("GET", S, undefined, T.sully)).owner_suspended, true);
450+ assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for every repository of olive by a site administrator: abuse report/);
451+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /every repository of olive/);
452+ const during = (await runs()).length;
453+ await push("three.txt", "three\n");
454+ await sleep(3000);
455+ assert.equal((await runs()).length, during);
456+ assert.match(await html(`/${OWNER}/halted/actions`), /Actions are suspended for every repository of olive by a site administrator/);
457+ assert.equal((await ok("DELETE", A, undefined, T.sully)).suspended, false);
458+ await until("the account's audit entries", async () => auditLog("actions.suspend").some((e) => e.target === OWNER && JSON.parse(e.detail).kind === "account") && auditLog("actions.unsuspend").some((e) => e.target === OWNER));
459+
460+ // ------------------------------------------------------------ the instance: hosted compute stops, self-hosted runners go on
461+ const p = await ok("PUT", P, { reason: "cost overrun" }, T.sully);
462+ assert.deepEqual([p.paused, p.reason, p.paused_by], [true, "cost overrun", "sully"]);
463+ assert.equal((await ok("GET", P, undefined, T.sully)).paused, true);
464+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /Agent sandboxes are paused on this instance by a site administrator: cost overrun/);
465+ assert.match(await html("/settings/admin", T.sully), /Hosted compute is paused on this instance/);
466+ assert.match(await html(`/${OWNER}/halted/actions`), /Hosted runners are paused on this instance by a site administrator/);
467+ await push(".github/workflows/hosted.yml", "name: hosted\non: push\njobs:\n cloud:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n");
468+ const hosted = await until("the hosted job to fail", async () => {
469+ const run = (await runs()).find((r) => r.name === "hosted");
470+ if (!run) return null;
471+ const [job] = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs;
472+ return job?.status === "completed" ? { run: run.id, job } : null;
473+ });
474+ assert.equal(hosted.job.conclusion, "failure");
475+ assert.match(await html(`/${OWNER}/halted/actions/runs/${hosted.run}`), /The job was not started: Hosted runners are paused on this instance by a site administrator: cost overrun/);
476+ // The self-hosted job of the same push is still handed to a runner.
477+ const claim = await until("a self-hosted job claimed", async () => {
478+ const r = await runnerClaim(R, ["linux"]);
479+ return r.status === 200 ? r.json : null;
480+ });
481+ await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
482+ assert.equal((await ok("DELETE", P, undefined, T.sully)).paused, false);
483+ assert.doesNotMatch(await html("/settings/admin", T.sully), /Hosted compute is paused/);
484+ assert.notEqual((await api("POST", `${R}/diffinery/sandboxes`, {})).status, 403);
485+ await until("the pause's audit entries", async () => auditLog("compute.pause").some((e) => e.actor_login === "sully" && JSON.parse(e.detail).reason === "cost overrun") && auditLog("compute.unpause").length > 0);
486+ } finally {
487+ for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully);
488+ }
489+});
490+
491+test("security: Markdown images from other sites load through Diffinery's signed image proxy", async () => {
492+ // A stand-in for another site, which records what it is sent.
493+ const seen = [];
494+ const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea7d6a4c50000000049454e44ae426082", "hex");
495+ const site = createHttpServer((req, res) => {
496+ seen.push({ url: req.url, headers: req.headers });
497+ if (req.url === "/pixel.png") return res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" }).end(PNG);
498+ if (req.url === "/moved.png") return res.writeHead(302, { location: "/pixel.png" }).end();
499+ if (req.url === "/page.html") return res.writeHead(200, { "content-type": "text/html" }).end("<script>alert(1)</script>");
500+ if (req.url === "/drawing.svg") return res.writeHead(200, { "content-type": "image/svg+xml" }).end('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
501+ res.writeHead(404).end();
502+ });
503+ await new Promise((r) => site.listen(0, "127.0.0.1", r));
504+ const other = `http://127.0.0.1:${site.address().port}`;
505+ try {
506+ await repoWithReadme("pictures");
507+ const body = [`![pixel](${other}/pixel.png)`, `[![moved](${other}/moved.png)](${other}/page.html)`, `![page](${other}/page.html)`, `![drawing](${other}/drawing.svg)`, "![own](/static/none.png)"].join("\n\n");
508+ const issue = await ok("POST", `/repos/${OWNER}/pictures/issues`, { title: "pictures", body });
509+ const page = await fetch(`${base}/${OWNER}/pictures/issues/${issue.number}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } });
510+ assert.equal(page.status, 200);
511+ const csp = page.headers.get("content-security-policy") ?? "";
512+ assert.match(csp, /img-src 'self' data: blob:(;|$)/);
513+ assert.doesNotMatch(csp, /img-src[^;]*https:/);
514+ const html = await page.text();
515+ const srcs = [...html.matchAll(/<img src="([^"]*)" alt="(pixel|moved|page|drawing|own)"/g)].map((m) => [m[2], m[1].replaceAll("&amp;", "&")]);
516+ const src = Object.fromEntries(srcs);
517+ assert.deepEqual(Object.keys(src).sort(), ["drawing", "moved", "own", "page", "pixel"]);
518+ for (const name of ["pixel", "moved", "page", "drawing"]) assert.match(src[name], /^\/-\/camo\/[\w-]{43}\/[\w-]+$/, name);
519+ assert.equal(src.own, `/${OWNER}/pictures/raw/main/static/none.png`);
520+ assert.ok(!html.includes(`src="${other}`), "no image loads from the other site directly");
521+ // Links keep their original target.
522+ assert.ok(html.includes(`href="${other}/page.html"`));
523+ assert.equal(seen.length, 0, "rendering fetches nothing");
524+
525+ // The proxy fetches the image without the viewer's cookies, and answers from its cache next time.
526+ const get = (path, init = {}) => fetch(`${base}${path}`, init);
527+ const first = await get(src.pixel, { headers: { cookie: `__Host-dfy_session=${S.olive}`, "user-agent": "viewer-browser/1" } });
528+ assert.equal(first.status, 200);
529+ assert.equal(first.headers.get("content-type"), "image/png");
530+ assert.equal(first.headers.get("x-content-type-options"), "nosniff");
531+ assert.match(first.headers.get("cache-control") ?? "", /^public, max-age=3600$/);
532+ assert.equal(first.headers.get("set-cookie"), null);
533+ assert.deepEqual(Buffer.from(await first.arrayBuffer()), PNG);
534+ assert.equal(seen.length, 1);
535+ assert.equal(seen[0].headers.cookie, undefined);
536+ assert.match(seen[0].headers["user-agent"] ?? "", /^Diffinery-Camo/);
537+ assert.equal((await get(src.pixel)).status, 200);
538+ assert.equal(seen.filter((r) => r.url === "/pixel.png").length, 1, "the second view is served from the cache");
539+
540+ // Redirects are followed; HTML is refused; SVG can't run scripts.
541+ assert.equal((await get(src.moved)).status, 200);
542+ assert.equal((await get(src.page)).status, 404);
543+ const svg = await get(src.drawing);
544+ assert.equal(svg.status, 200);
545+ assert.equal(svg.headers.get("content-type"), "image/svg+xml");
546+ assert.equal(svg.headers.get("content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; sandbox");
547+
548+ // Only URLs Diffinery signed: a tampered signature or URL, or one Diffinery never signed, is a 404 that fetches nothing.
549+ const before = seen.length;
550+ const [, , , sig, enc] = src.pixel.split("/");
551+ const evil = Buffer.from(`${other}/page.html?x`).toString("base64url");
552+ for (const path of [`/-/camo/${sig}/${evil}`, `/-/camo/${sig.slice(0, -2)}AA/${enc}`, `/-/camo/x/${enc}`, `/-/camo/${sig}`, `/-/camo/${"A".repeat(43)}/${Buffer.from(base).toString("base64url")}`]) {
553+ assert.equal((await get(path)).status, 404, path);
554+ }
555+ assert.equal(seen.length, before);
556+ } finally {
557+ site.close();
558+ }
559+});
modifiedapps/diffinery/test/e2e/security.test.mjs+8−779View file
@@ -20,206 +20,18 @@
2020 // images) that only readers can follow; emptied organizations can be deleted.
2121 // Agents land changes on protected branches only through auto-merge (or the
2222 // merge queue) where a person allowed it and a required check gates it, never by bypass.
23-// Site administrators' kill switches stop Actions and sandboxes for a repository, an
24-// account or (hosted compute) the whole instance. Images from other sites in Markdown
25-// load through Diffinery's signed image proxy, never straight from the other site.
26-import { spawn, spawnSync, execFileSync } from "node:child_process";
27-import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
28-import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23+// Continued in security-limits.test.mjs (environment reviewers, quotas, kill switches, the image
24+// proxy), which runs beside this file on a server of its own; the setup is in security-harness.mjs.
25+import { spawnSync, execFileSync } from "node:child_process";
26+import { createHash, randomBytes } from "node:crypto";
27+import { mkdtempSync, writeFileSync } from "node:fs";
2928 import { gzipSync } from "node:zlib";
30-import { tmpdir } from "node:os";
31-import { join, dirname, resolve } from "node:path";
32-import { fileURLToPath } from "node:url";
33-import { createServer } from "node:net";
34-import { createServer as createHttpServer } from "node:http";
35-import { test, before, after } from "node:test";
29+import { join } from "node:path";
30+import { test } from "node:test";
3631 import assert from "node:assert/strict";
37-import { migratedState } from "./d1-state.mjs";
32+import { APP, work, base, hash, T, S, OWNER, sleep, until, api, runnerClaim, ok, status, b64, collaborator, repoWithReadme, auditLog, oliveSubmits, submitsAs, gitRun } from "./security-harness.mjs";
3833
39-const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
40-const work = mkdtempSync(join(tmpdir(), "diffinery-security-"));
41-let base;
42-let dev;
43-let devLog = "";
4434
45-const hash = (s) => createHash("sha256").update(s).digest("hex");
46-const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
47-const T = { sully: tok("sully"), olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
48-const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` };
49-const OWNER = "olive";
50-/** One minute of an environment's wait timer, in this Worker (WAIT_TIMER_MINUTE_MS). */
51-const TIMER_MINUTE_MS = 10_000;
52-
53-const freePort = () =>
54- new Promise((res) => {
55- const s = createServer();
56- s.listen(0, "127.0.0.1", () => {
57- const { port } = s.address();
58- s.close(() => res(port));
59- });
60- });
61-
62-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63-async function until(what, fn, timeoutMs = 60_000) {
64- const end = Date.now() + timeoutMs;
65- while (Date.now() < end) {
66- const v = await fn();
67- if (v) return v;
68- await sleep(300);
69- }
70- assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
71-}
72-
73-/** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */
74-async function api(method, path, body, as = T.olive) {
75- const headers = { "content-type": "application/json" };
76- if (typeof as === "string") headers.authorization = `Bearer ${as}`;
77- else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`;
78- const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
79- // wrangler dev closes idle keep-alive connections (the audit checks pause for seconds), so a reused one can be gone.
80- const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e)));
81- const text = await res.text();
82- let json = null;
83- try {
84- json = text ? JSON.parse(text) : null;
85- } catch {
86- json = text;
87- }
88- return { status: res.status, json };
89-}
90-
91-/**
92- * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
93- * does (personal tokens can't claim jobs): one runner per repository and set of labels.
94- */
95-const e2eRunners = new Map();
96-async function runnerClaim(repoPath, labels = ["linux"]) {
97- const key = `${repoPath} ${labels.join(",")}`;
98- if (!e2eRunners.has(key)) {
99- const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
100- assert.equal(reg.status, 201, JSON.stringify(reg.json));
101- const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
102- assert.equal(runner.status, 201, JSON.stringify(runner.json));
103- e2eRunners.set(key, runner.json.token);
104- }
105- const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
106- // A repository deleted and created again under the same name needs a new runner.
107- if (r.status !== 401) return r;
108- e2eRunners.delete(key);
109- return runnerClaim(repoPath, labels);
110-}
111-async function ok(method, path, body, as) {
112- const r = await api(method, path, body, as);
113- assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
114- return r.json;
115-}
116-async function status(expected, method, path, body, as) {
117- const r = await api(method, path, body, as);
118- assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
119- return r.json;
120-}
121-const b64 = (s) => Buffer.from(s).toString("base64");
122-
123-/**
124- * Give someone access to a repository as GitHub does: someone new gets an invitation (201), which
125- * they accept with their own token; an existing collaborator's role changes at once (204).
126- */
127-async function collaborator(repo, login, permission, invitee, as = T.olive) {
128- const r = await api("PUT", `${repo}/collaborators/${login}`, { permission }, as);
129- if (r.status === 204) return;
130- assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`);
131- await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee);
132-}
133-
134-before(async () => {
135- const port = await freePort();
136- base = `http://127.0.0.1:${port}`;
137- const persist = join(work, "state");
138- const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
139- migratedState(APP, persist);
140- const at = new Date().toISOString();
141- const later = new Date(Date.now() + 30 * 86400_000).toISOString();
142- const account = (id, login, kind, status, owner = null) => `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', '${status}', ${owner ? `'${owner}'` : "NULL"}, '${at}')`;
143- const token = (id, account, t, scopes) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '${JSON.stringify(scopes)}', '${later}', '${at}')`;
144- const session = (id, account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
145- wrangler(
146- "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
147- [
148- account("u-olive", "olive", "user", "active"),
149- account("u-sully", "sully", "user", "active"),
150- "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'",
151- token("t-sully", "u-sully", T.sully, ["admin"]),
152- account("a-bot", "olive-bot", "agent", "active", "u-olive"),
153- account("u-rita", "rita", "user", "active"),
154- account("u-penny", "penny", "user", "pending"),
155- account("u-dora", "dora", "user", "disabled"),
156- account("u-gone", "gone", "user", "disabled"),
157- account("a-orphan", "gone-bot", "agent", "active", "u-gone"),
158- token("t1", "u-olive", T.olive, ["admin"]),
159- token("t2", "a-bot", T.bot, ["admin"]),
160- token("t3", "u-rita", T.rita, ["write"]),
161- token("t4", "u-dora", T.dora, ["write"]),
162- token("t5", "a-orphan", T.orphan, ["write"]),
163- account("o-guild", "guild", "org", "active"),
164- `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`,
165- token("t6", "u-olive", T.reader, ["read"]),
166- token("t7", "u-rita", T.ritaAdmin, ["admin"]),
167- account("u-milo", "milo", "user", "active"),
168- token("t8", "u-milo", T.milo, ["admin"]),
169- `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-milo', 'member', '${at}')`,
170- session("s1", "u-penny", S.penny),
171- session("s2", "u-dora", S.dora),
172- session("s3", "u-olive", S.olive),
173- account("o-shop", "shop", "org", "active"),
174- account("u-sam", "sam", "user", "active"),
175- token("t9", "u-sam", T.sam, ["admin"]),
176- account("a-helper", "olive-helper", "agent", "active", "u-olive"),
177- token("t10", "a-helper", T.helper, ["write"]),
178- `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
179- session("s4", "u-sam", S.sam),
180- "UPDATE accounts SET email = 'sam@example.com' WHERE id = 'u-sam'",
181- // olive's other browsers: one signed in 3 hours ago, one 100 days ago (used every day since), one spare.
182- session("s5", "u-olive", S.oliveOld).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 3 * 3600_000).toISOString()}', '${at}'`),
183- session("s6", "u-olive", S.oliveStale).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 100 * 86400_000).toISOString()}', '${at}'`),
184- session("s7", "u-olive", S.oliveSpare),
185- ].join("; "),
186- );
187- dev = spawn(
188- "pnpm",
189- ["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", `WAIT_TIMER_MINUTE_MS:${TIMER_MINUTE_MS}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"],
190- { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
191- );
192- dev.stdout.on("data", (d) => (devLog += d));
193- dev.stderr.on("data", (d) => (devLog += d));
194- await until(
195- "wrangler dev",
196- async () => {
197- try {
198- return (await fetch(`${base}/healthz`)).ok;
199- } catch {
200- return false;
201- }
202- },
203- 120_000,
204- );
205-});
206-
207-after(() => {
208- if (dev?.pid) {
209- try {
210- process.kill(-dev.pid, "SIGTERM");
211- } catch {}
212- }
213- if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
214-});
215-
216-/** A private repository owned by olive with one commit on main. */
217-async function repoWithReadme(name, extra = {}) {
218- await ok("POST", "/user/repos", { name, ...extra });
219- const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) });
220- return put.commit.sha;
221-}
222-
22335 test("security: search, required checks, environments, reviews, locks, archives, drafts and tree names", async () => {
22436 // ------------------------------------------------------------ setup: a private repo the agent and rita can use
22537 const R = `/repos/${OWNER}/guarded`;
@@ -572,23 +384,6 @@
572384 assert.notEqual(bypassed.sha, mainSha);
573385 });
574386
575-/** Audit entries recorded so far, read straight from the local D1 database. The running dev server can hold its lock, so a busy database is retried. */
576-function auditLog(action) {
577- for (let attempt = 1; ; attempt++) {
578- try {
579- return readAuditLog(action);
580- } catch (e) {
581- if (attempt >= 10 || !/SQLITE_BUSY|database is locked/.test(String(e.stderr ?? e.message))) throw e;
582- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300 * attempt);
583- }
584- }
585-}
586-
587-function readAuditLog(action) {
588- const out = execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", join(work, "state"), "--json", "--command", `SELECT actor_login, action, target, detail FROM audit_log WHERE action = '${action}'`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
589- return JSON.parse(out.toString())[0].results;
590-}
591-
592387 test("security: limited tokens, invisible repositories, readers' labels and the audit trail", async () => {
593388 await repoWithReadme("limits");
594389 // ------------------------------------------------------------ a token limited to some repositories can't mint one that isn't
@@ -716,19 +511,6 @@
716511 });
717512
718513 /** A web form post as olive (signed in by session), with the CSRF pair taken from `page`. */
719-const oliveSubmits = (page, action, fields) => submitsAs(S.olive, page, action, fields);
720-
721-/** A web form post signed in with `session`, with the CSRF pair taken from `page`. */
722-async function submitsAs(session, page, action, fields) {
723- const cookie = `__Host-dfy_session=${session}`;
724- const res = await fetch(`${base}${page}`, { headers: { cookie } });
725- const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
726- const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
727- assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`);
728- const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } });
729- return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() };
730-}
731-
732514 /** Call an MCP tool with a token. */
733515 async function mcpTool(as, name, args) {
734516 const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${as}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
@@ -804,13 +586,6 @@
804586 return { status: res.status, location: res.headers.get("location"), json, text };
805587 }
806588
807-/** Runs git, returning its stderr; fails the test when git fails. */
808-function gitRun(args, cwd) {
809- const r = spawnSync("git", ["-c", "protocol.version=2", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd, encoding: "utf8" });
810- assert.equal(r.status, 0, `git ${args.join(" ")} failed:\n${r.stderr}`);
811- return r.stderr;
812-}
813-
814589 /** Uploads a blob, then a one-layer manifest tagged `tag`, through the registry API. */
815590 async function pushTinyImage(name, tag, as) {
816591 const auth = { authorization: `Basic ${Buffer.from(`anyone:${as}`).toString("base64")}` };
@@ -2246,549 +2021,3 @@
22462021 // Names people would trust aren't for the taking.
22472022 for (const login of ["ghost", "github-actions", "noreply", "support", "abuse"]) await status(422, "POST", "/user/orgs", { login });
22482023 });
2249-
2250-test("security: environment reviewers hold a deploy job until a listed person approves, and nobody else can", async () => {
2251- const R = `/repos/${OWNER}/gated-deploy`;
2252- await repoWithReadme("gated-deploy");
2253- await collaborator(R, "rita", "read", T.rita);
2254- await collaborator(R, "sam", "read", T.sam);
2255- await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
2256- const ritaId = (await ok("GET", "/user", undefined, T.rita)).id;
2257-
2258- // Only people with access can be reviewers, never an agent or a team; limits are GitHub's; agents can't set them.
2259- await ok("PUT", `${R}/environments/production`, {});
2260- await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "olive-bot" }] });
2261- await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "Team", id: 1 }] });
2262- await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "milo" }] });
2263- await status(422, "PUT", `${R}/environments/production`, { wait_timer: 43_201 });
2264- await status(403, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "rita" }] }, T.bot);
2265- await status(201, "PUT", `${R}/environments/production/secrets/DEPLOY_KEY`, { value: "deploy-value" });
2266- const env = await ok("PUT", `${R}/environments/production`, { reviewers: [{ type: "User", id: ritaId }, { type: "User", login: "sam" }, { type: "User", login: "olive" }], prevent_self_review: true, deployment_branch_policy: null });
2267- assert.equal(env.can_admins_bypass, false);
2268- const rule = env.protection_rules.find((r) => r.type === "required_reviewers");
2269- assert.deepEqual(rule.reviewers.map((r) => r.reviewer.login).sort(), ["olive", "rita", "sam"]);
2270- assert.equal(rule.prevent_self_review, true);
2271-
2272- const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`;
2273- const claim = async () => {
2274- const r = await runnerClaim(R, ["linux"]);
2275- if (r.status !== 200) return null;
2276- const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
2277- await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
2278- return spec;
2279- };
2280- const waitingRun = async (sha) => {
2281- const run = await until("a run waiting for review", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs.find((r) => r.head_sha === sha && r.status === "waiting"));
2282- const jobs = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs;
2283- assert.equal(jobs.length, 1);
2284- assert.equal(jobs[0].status, "waiting");
2285- // Held before anything starts: no runner can claim it, so no token or secret has left.
2286- assert.equal(await claim(), null);
2287- return run;
2288- };
2289-
2290- // olive pushes: the job waits, and the deployment says so.
2291- const first = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy", content: b64(wf(1)) })).commit.sha;
2292- const run = await waitingRun(first);
2293- const pendingAs = async (as, id = run.id) => (await ok("GET", `${R}/actions/runs/${id}/pending_deployments`, undefined, as))[0];
2294- const p = await pendingAs(T.rita);
2295- assert.equal(p.environment.name, "production");
2296- assert.equal(p.current_user_can_approve, true);
2297- assert.equal((await pendingAs(T.olive)).current_user_can_approve, false, "olive started the run and the environment prevents self-review");
2298- const deployment = (await ok("GET", `${R}/deployments?environment=production`))[0];
2299- assert.equal((await ok("GET", `${R}/deployments/${deployment.id}/statuses`))[0].state, "waiting");
2300-
2301- // Nobody but a listed person, other than the run's starter, with a token that may write, decides.
2302- const body = { environment_ids: [p.environment.id], state: "approved", comment: "ship it" };
2303- assert.match((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body)).json.message, /doesn't let whoever started a run approve it/);
2304- assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.bot)).status, 403);
2305- assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.reader)).status, 403);
2306- assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.milo)).status, 404);
2307- await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, environment_ids: [999] }, T.rita);
2308- const approved = await ok("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.rita);
2309- assert.equal(approved[0].environment, "production");
2310- // One decision counts: a second one finds nothing waiting.
2311- await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, state: "rejected" }, T.sam);
2312-
2313- // Now the job starts, and only now gets the environment's secret.
2314- const spec = await until("the approved job", claim);
2315- assert.equal(spec.secrets.DEPLOY_KEY, "deploy-value");
2316- const done = await until("the run done", async () => {
2317- const r = await ok("GET", `${R}/actions/runs/${run.id}`);
2318- return r.status === "completed" ? r : null;
2319- });
2320- assert.equal(done.conclusion, "success");
2321- const approvals = await ok("GET", `${R}/actions/runs/${run.id}/approvals`);
2322- assert.deepEqual(approvals.map((a) => [a.state, a.user.login, a.comment, a.environments[0].name]), [["approved", "rita", "ship it", "production"]]);
2323- assert.deepEqual((await ok("GET", `${R}/deployments/${deployment.id}/statuses`)).map((s) => s.state), ["success", "in_progress", "waiting"]);
2324-
2325- // A rejection fails the job without starting it. sam decides in the browser, from the run page.
2326- const file = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
2327- const second = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 2", content: b64(wf(2)), sha: file.sha })).commit.sha;
2328- const run2 = await waitingRun(second);
2329- const page = await (await fetch(`${base}/${OWNER}/gated-deploy/actions/runs/${run2.id}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text();
2330- assert.match(page, /Waiting for review/);
2331- assert.match(page, /You started this run, and this environment doesn&#39;t let you approve it|You started this run, and this environment doesn't let you approve it/);
2332- const envId = (await pendingAs(T.rita, run2.id)).environment.id;
2333- const rejected = await submitsAs(S.sam, `/${OWNER}/gated-deploy/actions/runs/${run2.id}`, `/${OWNER}/gated-deploy/actions/runs/${run2.id}/review`, { environment: String(envId), comment: "not today", state: "rejected" });
2334- assert.equal(rejected.status, 303);
2335- assert.match(rejected.flash, /Rejected/);
2336- const failed = await until("the rejected run done", async () => {
2337- const r = await ok("GET", `${R}/actions/runs/${run2.id}`);
2338- return r.status === "completed" ? r : null;
2339- });
2340- assert.equal(failed.conclusion, "failure");
2341- const job = (await ok("GET", `${R}/actions/runs/${run2.id}/jobs`)).jobs[0];
2342- assert.match(job.failure.message, /rejected by sam: not today/);
2343- assert.equal(await claim(), null);
2344-
2345- // Self-review compares accounts, not names: sam starts a run, renames, and still can't approve it.
2346- await collaborator(R, "sam", "write", T.sam);
2347- const file3 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
2348- const fourth = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 4", content: b64(wf(4)), sha: file3.sha }, T.sam)).commit.sha;
2349- const run4 = await waitingRun(fourth);
2350- const env4 = (await pendingAs(T.rita, run4.id)).environment.id;
2351- const renamed = await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam-renamed", name: "sam" });
2352- assert.equal(renamed.status, 303);
2353- try {
2354- assert.equal((await ok("GET", "/user", undefined, T.sam)).login, "sam-renamed");
2355- assert.equal((await pendingAs(T.sam, run4.id)).current_user_can_approve, false);
2356- const self = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.sam);
2357- assert.equal(self.status, 403);
2358- assert.match(self.json.message, /doesn't let whoever started a run approve it/);
2359- } finally {
2360- assert.equal((await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam", name: "sam" })).status, 303);
2361- }
2362-
2363- // Access counts when deciding, not only when the list was set: on a public repository, where
2364- // anyone may read, a reviewer removed from it no longer approves.
2365- await ok("PATCH", R, { visibility: "public" });
2366- await status(204, "DELETE", `${R}/collaborators/rita`);
2367- assert.equal((await pendingAs(T.rita, run4.id)).current_user_can_approve, false);
2368- const removed = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.rita);
2369- assert.equal(removed.status, 403);
2370- assert.match(removed.json.message, /only people with access to this repository/);
2371- await ok("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "rejected", comment: "cleanup" });
2372- await ok("PATCH", R, { visibility: "private" });
2373- await collaborator(R, "rita", "read", T.rita);
2374-
2375- // A wait timer alone holds the job until it passes; nobody skips it.
2376- await ok("PUT", `${R}/environments/production`, { reviewers: [], wait_timer: 1 });
2377- const file2 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`);
2378- const third = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 3", content: b64(wf(3)), sha: file2.sha })).commit.sha;
2379- const run3 = await waitingRun(third);
2380- const timer = await pendingAs(T.rita, run3.id);
2381- assert.equal(timer.wait_timer, 1);
2382- assert.equal(timer.current_user_can_approve, false);
2383- await status(422, "POST", `${R}/actions/runs/${run3.id}/pending_deployments`, { environment_ids: [timer.environment.id], state: "approved" }, T.rita);
2384- // The Worker runs with a short wait-timer minute (WAIT_TIMER_MINUTE_MS), so the timer passes in seconds.
2385- if (Date.now() < Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 2000) assert.equal(await claim(), null, "nothing starts before the timer passes");
2386- const timed = await until("the job after its wait timer", claim, 60_000);
2387- assert.ok(Date.now() >= Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 1000, "the job waited for its timer");
2388- assert.equal(timed.secrets.DEPLOY_KEY, "deploy-value");
2389-});
2390-
2391-test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => {
2392- // ------------------------------------------------------------ a repository: milo is invited, not added
2393- await repoWithReadme("by-name");
2394- const R = `/repos/${OWNER}/by-name`;
2395- const inv = await status(201, "PUT", `${R}/collaborators/milo`, { permission: "write" });
2396- assert.equal(inv.invitee.login, "milo");
2397- assert.equal(inv.permissions, "write");
2398- await status(404, "GET", R, undefined, T.milo);
2399- // The admin's list names the username, never the person's email address.
2400- const listed = await ok("GET", `${R}/invitations`);
2401- assert.deepEqual(listed.map((i) => [i.login, i.email, i.role]), [["milo", null, "write"]]);
2402- // In-app and in milo's own list.
2403- assert.ok((await ok("GET", "/notifications", undefined, T.milo)).some((n) => /olive invited you to olive\/by-name/.test(n.title)));
2404- const mine = await ok("GET", "/user/repository_invitations", undefined, T.milo);
2405- assert.deepEqual(mine.map((i) => [i.id, i.repository.full_name]), [[inv.id, `${OWNER}/by-name`]]);
2406- // Nobody else can accept it, and a read token can't either.
2407- await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.rita);
2408- assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
2409- await status(404, "GET", R, undefined, T.rita);
2410- // Asking again changes the pending invitation's role rather than sending another.
2411- assert.equal((await status(201, "PUT", `${R}/collaborators/milo`, { permission: "admin" })).id, inv.id);
2412- // milo accepts and has the role offered.
2413- await status(204, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
2414- assert.equal((await ok("GET", R, undefined, T.milo)).permissions.admin, true);
2415- await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
2416- // A collaborator's role then changes at once.
2417- await status(204, "PUT", `${R}/collaborators/milo`, { permission: "read" });
2418- assert.equal((await ok("GET", R, undefined, T.milo)).permissions.push, false);
2419- // The settings form that sets a role invites someone new the same way.
2420- const viaForm = await oliveSubmits(`/${OWNER}/by-name/settings/access`, `/${OWNER}/by-name/settings/access`, { login: "rita", role: "write" });
2421- assert.equal(viaForm.status, 303);
2422- assert.match(viaForm.flash, /Invited rita/);
2423- await status(404, "GET", R, undefined, T.rita);
2424- // rita declines: no access, and the invitation is gone.
2425- const ritas = await ok("GET", "/user/repository_invitations", undefined, T.rita);
2426- assert.equal(ritas.length, 1);
2427- await status(204, "DELETE", `/user/repository_invitations/${ritas[0].id}`, undefined, T.rita);
2428- await status(404, "GET", R, undefined, T.rita);
2429- assert.deepEqual(await ok("GET", `${R}/invitations`), []);
2430- // Agents are still added directly: they act for a person who already has access.
2431- await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
2432-
2433- // ------------------------------------------------------------ an organization: Add people and the role form both invite
2434- await ok("POST", "/user/orgs", { login: "by-name-org" });
2435- const members = async () => (await ok("GET", "/orgs/by-name-org/members")).map((u) => u.login);
2436- const added = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/people", { who: "milo", role: "member" });
2437- assert.equal(added.status, 303);
2438- assert.match(added.flash, /Invited milo to by-name-org/);
2439- const roleForm = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/members", { login: "sam", role: "admin" });
2440- assert.match(roleForm.flash, /Invited sam to by-name-org/);
2441- assert.deepEqual(await members(), ["olive"]);
2442- assert.equal((await ok("GET", "/user/memberships/orgs/by-name-org", undefined, T.milo)).state, "pending");
2443- assert.deepEqual((await ok("GET", "/user/memberships/orgs?state=pending", undefined, T.milo)).map((m) => m.organization.login), ["by-name-org"]);
2444- // milo accepts through the API, sam declines on the web.
2445- assert.equal((await ok("PATCH", "/user/memberships/orgs/by-name-org", { state: "active" }, T.milo)).state, "active");
2446- const samInvite = (await ok("GET", "/orgs/by-name-org/invitations")).find((i) => i.login === "sam");
2447- const page = await (await fetch(`${base}/settings/invitations`, { headers: { cookie: `__Host-dfy_session=${S.sam}` } })).text();
2448- assert.match(page, /by-name-org/);
2449- const declined = await submitsAs(S.sam, "/settings/invitations", `/settings/invitations/${samInvite.id}/decline`, {});
2450- assert.match(declined.flash, /Invitation declined/);
2451- assert.deepEqual((await members()).sort(), ["milo", "olive"]);
2452- await status(404, "GET", "/user/memberships/orgs/by-name-org", undefined, T.sam);
2453-
2454- // ------------------------------------------------------------ someone waiting for an invite accepts it from the waiting page
2455- await repoWithReadme("for-penny");
2456- const pennyInvite = await status(201, "PUT", `/repos/${OWNER}/for-penny/collaborators/penny`, { permission: "read" });
2457- const waiting = await (await fetch(base, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).text();
2458- assert.match(waiting, /Waiting for an invite/);
2459- assert.match(waiting, new RegExp(`${OWNER}/for-penny`));
2460- const accepted = await submitsAs(S.penny, "/", `/settings/invitations/${pennyInvite.id}/accept`, {});
2461- assert.equal(accepted.status, 303, accepted.flash);
2462- assert.equal(accepted.location, `/${OWNER}/for-penny`);
2463- assert.equal((await fetch(`${base}/${OWNER}/for-penny`, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).status, 200);
2464-
2465- // ------------------------------------------------------------ a repository with invitations can still be deleted
2466- await repoWithReadme("invited-then-deleted");
2467- await status(201, "PUT", `/repos/${OWNER}/invited-then-deleted/collaborators/rita`, { permission: "read" });
2468- await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`);
2469- assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
2470-});
2471-
2472-test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => {
2473- // Only site administrators see or change another account's quotas.
2474- await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 });
2475- await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita);
2476- await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully);
2477- const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully);
2478- assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]);
2479- await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully"));
2480- // rita sees her own usage and limits.
2481- const mine = await ok("GET", "/user/usage", undefined, T.rita);
2482- assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]);
2483-
2484- // ------------------------------------------------------------ repositories
2485- const owned = mine.repositories.count;
2486- await setRitaRepos(owned + 1);
2487- await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita);
2488- assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/);
2489-
2490- // ------------------------------------------------------------ hosted minutes: a hosted job isn't started
2491- const R = "/repos/rita/rita-quota-1";
2492- await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita);
2493- const job = await until("the job to fail", async () => {
2494- const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
2495- if (!runs.length) return null;
2496- const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
2497- return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null;
2498- });
2499- assert.equal(job.job.conclusion, "failure");
2500- const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text();
2501- assert.match(page, /used its 0 hosted minutes for this month/);
2502-
2503- // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit
2504- assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/);
2505- await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully);
2506- assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/);
2507-
2508- // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room
2509- const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita);
2510- await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita);
2511- const held = await until("a second run", async () => {
2512- const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
2513- if (runs.length < 2) return null;
2514- const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
2515- return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null;
2516- });
2517- await sleep(3000);
2518- assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued");
2519- // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev).
2520- await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully);
2521- await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000);
2522-});
2523-
2524-test("security: a per-account storage quota refuses pushes, Git LFS and release asset uploads past it; reads and deletes keep working", async () => {
2525- const R = "/repos/milo/hoard";
2526- await ok("POST", "/user/repos", { name: "hoard" }, T.milo);
2527- const usage = async () => (await ok("GET", "/user/usage", undefined, T.milo)).storage_bytes;
2528- assert.equal((await usage()).limit, 10 * 1024 ** 3);
2529- // Push main and a topic branch, and upload a release asset, while there is room.
2530- const clone = join(work, "hoard");
2531- const remote = `${base.replace("://", "://x:" + T.milo + "@")}/milo/hoard.git`;
2532- execFileSync("git", ["init", "-q", "-b", "main", clone]);
2533- const commit = (name) => {
2534- writeFileSync(join(clone, name), randomBytes(2000).toString("hex"));
2535- gitRun(["add", name], clone);
2536- gitRun(["commit", "-q", "-m", name], clone);
2537- };
2538- const push = (...args) => spawnSync("git", ["-c", "protocol.version=2", "push", remote, ...args], { cwd: clone, encoding: "utf8" });
2539- commit("a.txt");
2540- assert.equal(push("HEAD:main", "HEAD:refs/heads/topic").status, 0);
2541- // Repositories report what they store as it changes: wait until the count stops moving.
2542- const settled = async (what, test) => {
2543- let last = -1;
2544- return until(what, async () => {
2545- const u = (await usage()).used;
2546- if (u === last && test(u)) return u;
2547- last = u;
2548- await sleep(1000);
2549- return null;
2550- });
2551- };
2552- const pushed = await settled("the push to be counted", (u) => u > 2000);
2553- const rel = await ok("POST", `${R}/releases`, { tag_name: "v1", target_commitish: "main" }, T.milo);
2554- const upload = (bytes) => fetch(`${base}/api/uploads/repos/milo/hoard/releases/${rel.id}/assets?name=${bytes.length}.bin`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: bytes });
2555- const asset = await (await upload(randomBytes(3000))).json();
2556- assert.equal(asset.size, 3000);
2557- const used = await settled("the asset to be counted", (u) => u === pushed + 3000);
2558- assert.equal((await ok("GET", "/admin/accounts/milo/quotas", undefined, T.sully)).storage_bytes.used, used);
2559-
2560- // ------------------------------------------------------------ at the limit: nothing new is stored
2561- const set = await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: used }, T.sully);
2562- assert.deepEqual(set.storage_bytes, { used, limit: used });
2563- const full = new RegExp(`milo has reached its storage limit of ${used} bytes`);
2564- commit("b.txt");
2565- const refused = push("HEAD:main");
2566- assert.notEqual(refused.status, 0);
2567- assert.match(refused.stderr, new RegExp(`remote: Push refused: ${full.source}`));
2568- assert.match(refused.stderr, /storage limit reached/);
2569- const lfsOid = (bytes) => createHash("sha256").update(bytes).digest("hex");
2570- const lfsBlob = randomBytes(1000);
2571- const batch = async () => {
2572- const r = await fetch(`${base}/milo/hoard.git/info/lfs/objects/batch`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, accept: "application/vnd.git-lfs+json", "content-type": "application/vnd.git-lfs+json" }, body: JSON.stringify({ operation: "upload", objects: [{ oid: lfsOid(lfsBlob), size: lfsBlob.length }] }) });
2573- assert.equal(r.status, 200);
2574- return (await r.json()).objects[0];
2575- };
2576- const lfs = await batch();
2577- assert.equal(lfs.error.code, 507);
2578- assert.match(lfs.error.message, full);
2579- const put = await fetch(`${base}/milo/hoard.git/info/lfs/objects/${lfsOid(lfsBlob)}`, { method: "PUT", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: lfsBlob });
2580- assert.equal(put.status, 507);
2581- const late = await upload(randomBytes(10));
2582- assert.equal(late.status, 403);
2583- assert.match((await late.json()).message, full);
2584- assert.match((await status(403, "PUT", `${R}/contents/c.txt`, { message: "c", content: b64("more") }, T.milo)).message, full);
2585- assert.match((await status(403, "POST", `${R}/git/blobs`, { content: "more" }, T.milo)).message, full);
2586- // A chunked registry upload declares no length, so its bytes are counted as they arrive.
2587- const reg = { authorization: `Basic ${Buffer.from(`milo:${T.milo}`).toString("base64")}` };
2588- const started = await fetch(`${base}/v2/milo/hoard/blobs/uploads/`, { method: "POST", headers: reg });
2589- assert.equal(started.status, 202, await started.clone().text());
2590- const chunked = new ReadableStream({
2591- start(ctl) {
2592- ctl.enqueue(randomBytes(1000));
2593- ctl.close();
2594- },
2595- });
2596- const sneak = await fetch(`${base}${started.headers.get("location")}`, { method: "PATCH", headers: { ...reg, "content-type": "application/octet-stream" }, body: chunked, duplex: "half" });
2597- assert.equal(sneak.status, 403);
2598- assert.match(await sneak.text(), /milo has reached its storage limit/);
2599- // An upload refused at the quota can't finish, so it is gone with its bytes: a retry has to start again.
2600- assert.equal((await fetch(`${base}${started.headers.get("location")}`, { headers: reg })).status, 404);
2601-
2602- // ------------------------------------------------------------ reads, ref deletions and ref moves still work
2603- assert.equal((await ok("GET", `${R}/contents/a.txt`, undefined, T.milo)).size, 4000);
2604- assert.equal(push(":refs/heads/topic").status, 0);
2605- assert.equal(push("HEAD~1:refs/heads/copy").status, 0);
2606- assert.deepEqual((await ok("GET", `${R}/branches`, undefined, T.milo)).map((b) => b.name).sort(), ["copy", "main"]);
2607-
2608- // ------------------------------------------------------------ deleting brings usage down, and there is room again
2609- await status(204, "DELETE", `${R}/releases/assets/${asset.id}`, undefined, T.milo);
2610- await until("the asset's bytes to be given back", async () => (await usage()).used === used - 3000);
2611- assert.ok((await batch()).actions.upload);
2612- await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: null }, T.sully);
2613- assert.equal(push("HEAD:main").status, 0);
2614-});
2615-
2616-/** Set rita's repository quota (site administrators only). */
2617-async function setRitaRepos(n) {
2618- await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully);
2619-}
2620-
2621-test("security: kill switches suspend Actions for a repository or an account, and pause hosted compute on the instance", async () => {
2622- const R = `/repos/${OWNER}/halted`;
2623- const S = `/admin/repos/${OWNER}/halted/actions-suspension`;
2624- const A = `/admin/accounts/${OWNER}/actions-suspension`;
2625- const P = "/admin/hosted-compute-pause";
2626- await repoWithReadme("halted");
2627- const runs = async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs;
2628- const html = async (path, as = T.olive) => (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${as}` } })).text();
2629- const push = async (path, text) => ok("PUT", `${R}/contents/${path}`, { message: path, content: b64(text) });
2630- // A self-hosted job that no runner takes yet: the run stays in progress.
2631- await push(".github/workflows/ci.yml", "name: ci\non:\n push:\n workflow_dispatch:\n repository_dispatch:\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n");
2632- const first = await until("a run waiting for a runner", async () => {
2633- const [run] = await runs();
2634- return run && (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs.length ? run : null;
2635- });
2636-
2637- try {
2638- // ------------------------------------------------------------ only site administrators
2639- for (const path of [S, A, P]) {
2640- for (const method of ["GET", "PUT", "DELETE"]) assert.match((await status(403, method, path, method === "PUT" ? { reason: "mine" } : undefined, T.olive)).message, /only site administrators/);
2641- await status(401, "PUT", path, { reason: "no token" }, null);
2642- }
2643- await status(422, "PUT", S, {}, T.sully);
2644- await status(404, "PUT", `/admin/repos/${OWNER}/nowhere/actions-suspension`, { reason: "x" }, T.sully);
2645- await status(404, "PUT", "/admin/accounts/olive-bot/actions-suspension", { reason: "x" }, T.sully);
2646- assert.equal((await ok("GET", S, undefined, T.sully)).suspended, false);
2647-
2648- // ------------------------------------------------------------ a repository: what runs stops, nothing new starts
2649- const s = await ok("PUT", S, { reason: "crypto mining" }, T.sully);
2650- assert.deepEqual([s.suspended, s.reason, s.suspended_by, s.stopped.runs, s.stopped.jobs], [true, "crypto mining", "sully", 1, 1]);
2651- const cancelled = await until("the run to be cancelled", async () => {
2652- const r = await ok("GET", `${R}/actions/runs/${first.id}`);
2653- return r.status === "completed" ? r : null;
2654- });
2655- assert.equal(cancelled.conclusion, "cancelled");
2656- assert.match(await html(`/${OWNER}/halted/actions/runs/${first.id}`), /Cancelled: Actions are suspended for this repository by a site administrator: crypto mining/);
2657- assert.equal((await runnerClaim(R, ["linux"])).status, 204);
2658- assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for this repository by a site administrator: crypto mining/);
2659- assert.match((await status(403, "POST", `${R}/dispatches`, { event_type: "go" })).message, /suspended for this repository/);
2660- assert.match((await status(403, "POST", `${R}/actions/runs/${first.id}/rerun`)).message, /suspended for this repository/);
2661- assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /suspended for this repository/);
2662- const before = (await runs()).length;
2663- await push("one.txt", "one\n");
2664- await sleep(3000);
2665- assert.equal((await runs()).length, before, "a push starts no run while Actions are suspended");
2666- for (const path of [`/${OWNER}/halted/actions`, `/${OWNER}/halted/settings`]) assert.match(await html(path), /Actions are suspended for this repository by a site administrator/, path);
2667- assert.match(await html("/settings/admin/kill-switches", T.sully), new RegExp(`${OWNER}/halted`));
2668- await until("the suspension's audit entry", async () => auditLog("actions.suspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully" && JSON.parse(e.detail).reason === "crypto mining"));
2669-
2670- // Lifting it lets new work start; nothing restarts.
2671- assert.equal((await ok("DELETE", S, undefined, T.sully)).suspended, false);
2672- assert.equal((await ok("GET", `${R}/actions/runs/${first.id}`)).conclusion, "cancelled");
2673- await push("two.txt", "two\n");
2674- await until("a new run", async () => (await runs()).length > before);
2675- await ok("POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" });
2676- const sandbox = await api("POST", `${R}/diffinery/sandboxes`, {});
2677- assert.notEqual(sandbox.status, 403, JSON.stringify(sandbox.json)); // 503 here: no sandbox pool under this test
2678- await until("the lift's audit entry", async () => auditLog("actions.unsuspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully"));
2679-
2680- // ------------------------------------------------------------ an account: every repository it owns
2681- const a = await ok("PUT", A, { reason: "abuse report" }, T.sully);
2682- assert.deepEqual([a.account, a.suspended, a.reason], [OWNER, true, "abuse report"]);
2683- assert.ok(a.stopped.runs >= 1, JSON.stringify(a.stopped));
2684- assert.equal((await ok("GET", S, undefined, T.sully)).owner_suspended, true);
2685- assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for every repository of olive by a site administrator: abuse report/);
2686- assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /every repository of olive/);
2687- const during = (await runs()).length;
2688- await push("three.txt", "three\n");
2689- await sleep(3000);
2690- assert.equal((await runs()).length, during);
2691- assert.match(await html(`/${OWNER}/halted/actions`), /Actions are suspended for every repository of olive by a site administrator/);
2692- assert.equal((await ok("DELETE", A, undefined, T.sully)).suspended, false);
2693- await until("the account's audit entries", async () => auditLog("actions.suspend").some((e) => e.target === OWNER && JSON.parse(e.detail).kind === "account") && auditLog("actions.unsuspend").some((e) => e.target === OWNER));
2694-
2695- // ------------------------------------------------------------ the instance: hosted compute stops, self-hosted runners go on
2696- const p = await ok("PUT", P, { reason: "cost overrun" }, T.sully);
2697- assert.deepEqual([p.paused, p.reason, p.paused_by], [true, "cost overrun", "sully"]);
2698- assert.equal((await ok("GET", P, undefined, T.sully)).paused, true);
2699- assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /Agent sandboxes are paused on this instance by a site administrator: cost overrun/);
2700- assert.match(await html("/settings/admin", T.sully), /Hosted compute is paused on this instance/);
2701- assert.match(await html(`/${OWNER}/halted/actions`), /Hosted runners are paused on this instance by a site administrator/);
2702- await push(".github/workflows/hosted.yml", "name: hosted\non: push\njobs:\n cloud:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n");
2703- const hosted = await until("the hosted job to fail", async () => {
2704- const run = (await runs()).find((r) => r.name === "hosted");
2705- if (!run) return null;
2706- const [job] = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs;
2707- return job?.status === "completed" ? { run: run.id, job } : null;
2708- });
2709- assert.equal(hosted.job.conclusion, "failure");
2710- assert.match(await html(`/${OWNER}/halted/actions/runs/${hosted.run}`), /The job was not started: Hosted runners are paused on this instance by a site administrator: cost overrun/);
2711- // The self-hosted job of the same push is still handed to a runner.
2712- const claim = await until("a self-hosted job claimed", async () => {
2713- const r = await runnerClaim(R, ["linux"]);
2714- return r.status === 200 ? r.json : null;
2715- });
2716- await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
2717- assert.equal((await ok("DELETE", P, undefined, T.sully)).paused, false);
2718- assert.doesNotMatch(await html("/settings/admin", T.sully), /Hosted compute is paused/);
2719- assert.notEqual((await api("POST", `${R}/diffinery/sandboxes`, {})).status, 403);
2720- await until("the pause's audit entries", async () => auditLog("compute.pause").some((e) => e.actor_login === "sully" && JSON.parse(e.detail).reason === "cost overrun") && auditLog("compute.unpause").length > 0);
2721- } finally {
2722- for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully);
2723- }
2724-});
2725-
2726-test("security: Markdown images from other sites load through Diffinery's signed image proxy", async () => {
2727- // A stand-in for another site, which records what it is sent.
2728- const seen = [];
2729- const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea7d6a4c50000000049454e44ae426082", "hex");
2730- const site = createHttpServer((req, res) => {
2731- seen.push({ url: req.url, headers: req.headers });
2732- if (req.url === "/pixel.png") return res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" }).end(PNG);
2733- if (req.url === "/moved.png") return res.writeHead(302, { location: "/pixel.png" }).end();
2734- if (req.url === "/page.html") return res.writeHead(200, { "content-type": "text/html" }).end("<script>alert(1)</script>");
2735- if (req.url === "/drawing.svg") return res.writeHead(200, { "content-type": "image/svg+xml" }).end('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
2736- res.writeHead(404).end();
2737- });
2738- await new Promise((r) => site.listen(0, "127.0.0.1", r));
2739- const other = `http://127.0.0.1:${site.address().port}`;
2740- try {
2741- await repoWithReadme("pictures");
2742- const body = [`![pixel](${other}/pixel.png)`, `[![moved](${other}/moved.png)](${other}/page.html)`, `![page](${other}/page.html)`, `![drawing](${other}/drawing.svg)`, "![own](/static/none.png)"].join("\n\n");
2743- const issue = await ok("POST", `/repos/${OWNER}/pictures/issues`, { title: "pictures", body });
2744- const page = await fetch(`${base}/${OWNER}/pictures/issues/${issue.number}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } });
2745- assert.equal(page.status, 200);
2746- const csp = page.headers.get("content-security-policy") ?? "";
2747- assert.match(csp, /img-src 'self' data: blob:(;|$)/);
2748- assert.doesNotMatch(csp, /img-src[^;]*https:/);
2749- const html = await page.text();
2750- const srcs = [...html.matchAll(/<img src="([^"]*)" alt="(pixel|moved|page|drawing|own)"/g)].map((m) => [m[2], m[1].replaceAll("&amp;", "&")]);
2751- const src = Object.fromEntries(srcs);
2752- assert.deepEqual(Object.keys(src).sort(), ["drawing", "moved", "own", "page", "pixel"]);
2753- for (const name of ["pixel", "moved", "page", "drawing"]) assert.match(src[name], /^\/-\/camo\/[\w-]{43}\/[\w-]+$/, name);
2754- assert.equal(src.own, `/${OWNER}/pictures/raw/main/static/none.png`);
2755- assert.ok(!html.includes(`src="${other}`), "no image loads from the other site directly");
2756- // Links keep their original target.
2757- assert.ok(html.includes(`href="${other}/page.html"`));
2758- assert.equal(seen.length, 0, "rendering fetches nothing");
2759-
2760- // The proxy fetches the image without the viewer's cookies, and answers from its cache next time.
2761- const get = (path, init = {}) => fetch(`${base}${path}`, init);
2762- const first = await get(src.pixel, { headers: { cookie: `__Host-dfy_session=${S.olive}`, "user-agent": "viewer-browser/1" } });
2763- assert.equal(first.status, 200);
2764- assert.equal(first.headers.get("content-type"), "image/png");
2765- assert.equal(first.headers.get("x-content-type-options"), "nosniff");
2766- assert.match(first.headers.get("cache-control") ?? "", /^public, max-age=3600$/);
2767- assert.equal(first.headers.get("set-cookie"), null);
2768- assert.deepEqual(Buffer.from(await first.arrayBuffer()), PNG);
2769- assert.equal(seen.length, 1);
2770- assert.equal(seen[0].headers.cookie, undefined);
2771- assert.match(seen[0].headers["user-agent"] ?? "", /^Diffinery-Camo/);
2772- assert.equal((await get(src.pixel)).status, 200);
2773- assert.equal(seen.filter((r) => r.url === "/pixel.png").length, 1, "the second view is served from the cache");
2774-
2775- // Redirects are followed; HTML is refused; SVG can't run scripts.
2776- assert.equal((await get(src.moved)).status, 200);
2777- assert.equal((await get(src.page)).status, 404);
2778- const svg = await get(src.drawing);
2779- assert.equal(svg.status, 200);
2780- assert.equal(svg.headers.get("content-type"), "image/svg+xml");
2781- assert.equal(svg.headers.get("content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; sandbox");
2782-
2783- // Only URLs Diffinery signed: a tampered signature or URL, or one Diffinery never signed, is a 404 that fetches nothing.
2784- const before = seen.length;
2785- const [, , , sig, enc] = src.pixel.split("/");
2786- const evil = Buffer.from(`${other}/page.html?x`).toString("base64url");
2787- for (const path of [`/-/camo/${sig}/${evil}`, `/-/camo/${sig.slice(0, -2)}AA/${enc}`, `/-/camo/x/${enc}`, `/-/camo/${sig}`, `/-/camo/${"A".repeat(43)}/${Buffer.from(base).toString("base64url")}`]) {
2788- assert.equal((await get(path)).status, 404, path);
2789- }
2790- assert.equal(seen.length, before);
2791- } finally {
2792- site.close();
2793- }
2794-});