| 1 | // End-to-end security checks of the GitHub-compatible REST API, against a real |
| 2 | // `wrangler dev` Worker with real (seeded) accounts, tokens and sessions. |
| 3 | // |
| 4 | // pnpm --filter @diffinery/app e2e |
| 5 | // |
| 6 | // Covers: search only shows what the caller may read (pending, disabled and |
| 7 | // orphaned-agent callers see nothing); agents can't turn a failing required |
| 8 | // check green; deployments verify every context; agents can't manage |
| 9 | // environments or dismiss reviews; secrets default environments to protected |
| 10 | // branches; locked conversations and archived repositories refuse changes; |
| 11 | // draft releases stay off readers' live sockets; unsafe tree names, tree bombs, |
| 12 | // long search queries and percent signs in label names. Anonymous callers and |
| 13 | // read-scoped tokens can't write; agents can't change secrets or variables; |
| 14 | // merges honour linear history, need an explicit admin bypass, and drafts can |
| 15 | // be marked ready; the exact branch protection rule wins over a pattern. |
| 16 | import { spawn, execFileSync } from "node:child_process"; |
| 17 | import { createHash, randomBytes } from "node:crypto"; |
| 18 | import { mkdtempSync, rmSync } from "node:fs"; |
| 19 | import { tmpdir } from "node:os"; |
| 20 | import { join, dirname, resolve } from "node:path"; |
| 21 | import { fileURLToPath } from "node:url"; |
| 22 | import { createServer } from "node:net"; |
| 23 | import { test, before, after } from "node:test"; |
| 24 | import assert from "node:assert/strict"; |
| 25 | |
| 26 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 27 | const work = mkdtempSync(join(tmpdir(), "diffinery-security-")); |
| 28 | let base; |
| 29 | let dev; |
| 30 | let devLog = ""; |
| 31 | |
| 32 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 33 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 34 | const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin") }; |
| 35 | const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}` }; |
| 36 | const OWNER = "olive"; |
| 37 | |
| 38 | const freePort = () => |
| 39 | new Promise((res) => { |
| 40 | const s = createServer(); |
| 41 | s.listen(0, "127.0.0.1", () => { |
| 42 | const { port } = s.address(); |
| 43 | s.close(() => res(port)); |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 48 | async function until(what, fn, timeoutMs = 60_000) { |
| 49 | const end = Date.now() + timeoutMs; |
| 50 | while (Date.now() < end) { |
| 51 | const v = await fn(); |
| 52 | if (v) return v; |
| 53 | await sleep(300); |
| 54 | } |
| 55 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 56 | } |
| 57 | |
| 58 | /** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */ |
| 59 | async function api(method, path, body, as = T.olive) { |
| 60 | const headers = { "content-type": "application/json" }; |
| 61 | if (typeof as === "string") headers.authorization = `Bearer ${as}`; |
| 62 | else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; |
| 63 | const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 64 | const text = await res.text(); |
| 65 | let json = null; |
| 66 | try { |
| 67 | json = text ? JSON.parse(text) : null; |
| 68 | } catch { |
| 69 | json = text; |
| 70 | } |
| 71 | return { status: res.status, json }; |
| 72 | } |
| 73 | async function ok(method, path, body, as) { |
| 74 | const r = await api(method, path, body, as); |
| 75 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 76 | return r.json; |
| 77 | } |
| 78 | async function status(expected, method, path, body, as) { |
| 79 | const r = await api(method, path, body, as); |
| 80 | assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 81 | return r.json; |
| 82 | } |
| 83 | const b64 = (s) => Buffer.from(s).toString("base64"); |
| 84 | |
| 85 | before(async () => { |
| 86 | const port = await freePort(); |
| 87 | base = `http://127.0.0.1:${port}`; |
| 88 | const persist = join(work, "state"); |
| 89 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 90 | wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 91 | const at = new Date().toISOString(); |
| 92 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 93 | 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}')`; |
| 94 | 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}')`; |
| 95 | 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}')`; |
| 96 | wrangler( |
| 97 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 98 | [ |
| 99 | account("u-olive", "olive", "user", "active"), |
| 100 | account("a-bot", "olive-bot", "agent", "active", "u-olive"), |
| 101 | account("u-rita", "rita", "user", "active"), |
| 102 | account("u-penny", "penny", "user", "pending"), |
| 103 | account("u-dora", "dora", "user", "disabled"), |
| 104 | account("u-gone", "gone", "user", "disabled"), |
| 105 | account("a-orphan", "gone-bot", "agent", "active", "u-gone"), |
| 106 | token("t1", "u-olive", T.olive, ["admin"]), |
| 107 | token("t2", "a-bot", T.bot, ["admin"]), |
| 108 | token("t3", "u-rita", T.rita, ["write"]), |
| 109 | token("t4", "u-dora", T.dora, ["write"]), |
| 110 | token("t5", "a-orphan", T.orphan, ["write"]), |
| 111 | account("o-guild", "guild", "org", "active"), |
| 112 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`, |
| 113 | token("t6", "u-olive", T.reader, ["read"]), |
| 114 | token("t7", "u-rita", T.ritaAdmin, ["admin"]), |
| 115 | session("s1", "u-penny", S.penny), |
| 116 | session("s2", "u-dora", S.dora), |
| 117 | ].join("; "), |
| 118 | ); |
| 119 | dev = spawn( |
| 120 | "pnpm", |
| 121 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], |
| 122 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 123 | ); |
| 124 | dev.stdout.on("data", (d) => (devLog += d)); |
| 125 | dev.stderr.on("data", (d) => (devLog += d)); |
| 126 | await until( |
| 127 | "wrangler dev", |
| 128 | async () => { |
| 129 | try { |
| 130 | return (await fetch(`${base}/healthz`)).ok; |
| 131 | } catch { |
| 132 | return false; |
| 133 | } |
| 134 | }, |
| 135 | 120_000, |
| 136 | ); |
| 137 | }); |
| 138 | |
| 139 | after(() => { |
| 140 | if (dev?.pid) { |
| 141 | try { |
| 142 | process.kill(-dev.pid, "SIGTERM"); |
| 143 | } catch {} |
| 144 | } |
| 145 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 146 | }); |
| 147 | |
| 148 | /** A private repository owned by olive with one commit on main. */ |
| 149 | async function repoWithReadme(name, extra = {}) { |
| 150 | await ok("POST", "/user/repos", { name, ...extra }); |
| 151 | const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }); |
| 152 | return put.commit.sha; |
| 153 | } |
| 154 | |
| 155 | test("security: search, required checks, environments, reviews, locks, archives, drafts and tree names", async () => { |
| 156 | // ------------------------------------------------------------ setup: a private repo the agent and rita can use |
| 157 | const R = `/repos/${OWNER}/guarded`; |
| 158 | const mainSha = await repoWithReadme("guarded"); |
| 159 | await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" }); |
| 160 | await status(204, "PUT", `${R}/collaborators/rita`, { permission: "read" }); |
| 161 | await status(204, "PUT", `${R}/collaborators/gone-bot`, { permission: "write" }); |
| 162 | const issue = await ok("POST", `${R}/issues`, { title: "needle in a haystack" }); |
| 163 | |
| 164 | // ------------------------------------------------------------ search only shows what the caller may read |
| 165 | const found = await ok("GET", "/search/issues?q=needle"); |
| 166 | assert.equal(found.total_count, 1); |
| 167 | assert.equal(found.items[0].number, issue.number); |
| 168 | assert.equal((await ok("GET", "/search/issues?q=needle", undefined, T.rita)).total_count, 1); |
| 169 | for (const [who, as] of [ |
| 170 | ["pending account (session)", { cookie: S.penny }], |
| 171 | ["disabled account (session)", { cookie: S.dora }], |
| 172 | ["agent of a disabled owner", T.orphan], |
| 173 | ["anonymous", null], |
| 174 | ]) { |
| 175 | const r = await api("GET", "/search/issues?q=needle", undefined, as); |
| 176 | assert.equal(r.status, 200, `${who}: ${JSON.stringify(r.json)}`); |
| 177 | assert.equal(r.json.total_count, 0, `${who} must not find private issues`); |
| 178 | const repos = await ok("GET", "/search/repositories?q=guarded", undefined, as); |
| 179 | assert.equal(repos.total_count, 0, `${who} must not find private repos`); |
| 180 | } |
| 181 | await status(401, "GET", "/search/issues?q=needle", undefined, T.dora); |
| 182 | await status(404, "GET", R, undefined, T.orphan); |
| 183 | // Search input is bounded. |
| 184 | await status(422, "GET", `/search/issues?q=${"x".repeat(300)}`); |
| 185 | await status(422, "GET", "/search/issues"); |
| 186 | |
| 187 | // ------------------------------------------------------------ required checks: an agent can't turn its failing PR green |
| 188 | await ok("PUT", `${R}/branches/main/protection`, { requiredChecks: ["ci"] }); |
| 189 | await ok("POST", `${R}/git/refs`, { ref: "refs/heads/agent-change", sha: mainSha }, T.bot); |
| 190 | const change = await ok("PUT", `${R}/contents/change.txt`, { message: "agent change", content: b64("hi\n"), branch: "agent-change" }, T.bot); |
| 191 | const head = change.commit.sha; |
| 192 | const pr = await ok("POST", `${R}/pulls`, { title: "Agent change", head: "agent-change", base: "main" }, T.bot); |
| 193 | await ok("POST", `${R}/pulls/${pr.number}/reviews`, { event: "APPROVE", body: "fine by me" }); |
| 194 | const failing = await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: head, status: "completed", conclusion: "failure" }); |
| 195 | assert.equal(typeof failing.id, "number"); |
| 196 | const mergeState = async () => |
| 197 | until("mergeability", async () => { |
| 198 | const p = await ok("GET", `${R}/pulls/${pr.number}`); |
| 199 | return typeof p.mergeable === "boolean" ? p.mergeable_state : null; |
| 200 | }); |
| 201 | assert.equal(await mergeState(), "blocked"); |
| 202 | // 1. PATCH someone else's check run. |
| 203 | await status(403, "PATCH", `${R}/check-runs/${failing.id}`, { conclusion: "success" }, T.bot); |
| 204 | // 2. A green commit status with the same name. |
| 205 | await ok("POST", `${R}/statuses/${head}`, { state: "success", context: "ci" }, T.bot); |
| 206 | assert.equal(await mergeState(), "blocked"); |
| 207 | // 3. A green check run of its own with the same name. |
| 208 | await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: head, status: "completed", conclusion: "success" }, T.bot); |
| 209 | assert.equal(await mergeState(), "blocked"); |
| 210 | |
| 211 | // ------------------------------------------------------------ deployments verify every context unless told otherwise |
| 212 | await status(409, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging" }); |
| 213 | await status(201, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging", required_contexts: [] }); |
| 214 | await status(409, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging", required_contexts: ["ci"] }); |
| 215 | |
| 216 | // The real reporter fixes its check: now the PR is mergeable and deployable. |
| 217 | await ok("PATCH", `${R}/check-runs/${failing.id}`, { conclusion: "success" }); |
| 218 | assert.equal(await mergeState(), "clean"); |
| 219 | await status(201, "POST", `${R}/deployments`, { ref: "agent-change", environment: "staging" }); |
| 220 | |
| 221 | // ------------------------------------------------------------ review dismissal: maintainers only, never the author or an agent |
| 222 | const reviews = await ok("GET", `${R}/pulls/${pr.number}/reviews`); |
| 223 | const approval = reviews.find((r) => r.state === "APPROVED"); |
| 224 | await status(403, "PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "nope" }, T.bot); |
| 225 | await status(403, "PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "nope" }, T.rita); |
| 226 | const dismissed = await ok("PUT", `${R}/pulls/${pr.number}/reviews/${approval.id}/dismissals`, { message: "stale" }); |
| 227 | assert.equal(dismissed.state, "DISMISSED"); |
| 228 | |
| 229 | // ------------------------------------------------------------ environments: admins who are people, not agents |
| 230 | await status(403, "PUT", `${R}/environments/production`, {}, T.bot); |
| 231 | await ok("PUT", `${R}/environments/production`, { deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } }); |
| 232 | await status(403, "POST", `${R}/environments/production/deployment-branch-policies`, { name: "v*", type: "tag" }, T.bot); |
| 233 | const policy = await ok("POST", `${R}/environments/production/deployment-branch-policies`, { name: "release/*", type: "branch" }); |
| 234 | await status(403, "DELETE", `${R}/environments/production/deployment-branch-policies/${policy.id}`, undefined, T.bot); |
| 235 | await status(403, "DELETE", `${R}/environments/production`, undefined, T.bot); |
| 236 | await status(403, "PUT", `${R}/environments/production/secrets/TOKEN`, { value: "s3cret" }, T.bot); |
| 237 | // The first secret limits an environment without rules to protected branches. |
| 238 | await ok("PUT", `${R}/environments/vault`, {}); |
| 239 | assert.equal((await ok("GET", `${R}/environments/vault`)).deployment_branch_policy, null); |
| 240 | await status(201, "PUT", `${R}/environments/vault/secrets/API_KEY`, { value: "s3cret" }); |
| 241 | const vault = await ok("GET", `${R}/environments/vault`); |
| 242 | assert.equal(vault.deployment_branch_policy?.protected_branches, true); |
| 243 | assert.ok(!JSON.stringify(vault).includes("s3cret")); |
| 244 | |
| 245 | // ------------------------------------------------------------ locked conversations |
| 246 | await status(204, "PUT", `${R}/issues/${pr.number}/lock`, { lock_reason: "resolved" }); |
| 247 | await status(403, "POST", `${R}/pulls/${pr.number}/comments`, { body: "late", commit_id: head, path: "change.txt", line: 1, side: "RIGHT" }, T.rita); |
| 248 | await status(403, "POST", `${R}/issues/${pr.number}/reactions`, { content: "+1" }, T.rita); |
| 249 | await status(201, "POST", `${R}/issues/${issue.number}/reactions`, { content: "+1" }, T.rita); |
| 250 | |
| 251 | // ------------------------------------------------------------ unsafe tree entry names, on every write path |
| 252 | for (const path of [".git/config", ".GIT/hooks/pre-commit", "x/.git./config", "git~1/config", "a\\b", "x/../y"]) { |
| 253 | await status(422, "POST", `${R}/git/trees`, { tree: [{ path, mode: "100644", type: "blob", content: "x" }] }); |
| 254 | } |
| 255 | for (const path of [".Git/hooks/post-checkout", "git~1/HEAD", "sub/.git/config"]) { |
| 256 | await status(422, "PUT", `${R}/contents/${path}`, { message: "bad", content: b64("x") }); |
| 257 | } |
| 258 | |
| 259 | // ------------------------------------------------------------ a tree bomb can't blow up archives or languages |
| 260 | const leaf = await ok("POST", `${R}/git/trees`, { tree: [{ path: "a.js", mode: "100644", type: "blob", content: "x" }] }); |
| 261 | const wide = await ok("POST", `${R}/git/trees`, { tree: Array.from({ length: 400 }, (_, i) => ({ path: `d${i}`, mode: "040000", type: "tree", sha: leaf.sha })) }); |
| 262 | const bomb = await ok("POST", `${R}/git/trees`, { tree: Array.from({ length: 400 }, (_, i) => ({ path: `e${i}`, mode: "040000", type: "tree", sha: wide.sha })) }); |
| 263 | const bombCommit = await ok("POST", `${R}/git/commits`, { message: "bomb", tree: bomb.sha, parents: [mainSha] }); |
| 264 | await ok("POST", `${R}/git/refs`, { ref: "refs/heads/bomb", sha: bombCommit.sha }); |
| 265 | const zip = await fetch(`${base}/api/v3${R}/zipball/bomb`, { headers: { authorization: `Bearer ${T.olive}` }, redirect: "manual" }); |
| 266 | assert.equal(zip.status, 413, await zip.text()); |
| 267 | await status(200, "GET", `${R}/languages`); |
| 268 | |
| 269 | // ------------------------------------------------------------ labels with a percent sign (no double decoding) |
| 270 | await ok("POST", `${R}/labels`, { name: "50%", color: "ff0000" }); |
| 271 | assert.equal((await ok("GET", `${R}/labels/50%25`)).name, "50%"); |
| 272 | assert.equal((await ok("PATCH", `${R}/labels/50%25`, { description: "half" })).description, "half"); |
| 273 | await ok("POST", `${R}/issues/${issue.number}/labels`, { labels: ["50%"] }); |
| 274 | await ok("DELETE", `${R}/issues/${issue.number}/labels/50%25`); |
| 275 | await status(204, "DELETE", `${R}/labels/50%25`); |
| 276 | |
| 277 | // ------------------------------------------------------------ archived repositories are read-only for everyone |
| 278 | const A = `/repos/${OWNER}/attic`; |
| 279 | await repoWithReadme("attic"); |
| 280 | const old = await ok("POST", `${A}/issues`, { title: "old" }); |
| 281 | assert.equal((await ok("PATCH", A, { archived: true })).archived, true); |
| 282 | await status(403, "POST", `${A}/issues/${old.number}/reactions`, { content: "heart" }); |
| 283 | await status(403, "PUT", `${A}/environments/production`, {}); |
| 284 | await status(403, "POST", `${A}/releases`, { tag_name: "v1" }); |
| 285 | await status(200, "GET", `${A}/labels`); |
| 286 | |
| 287 | // ------------------------------------------------------------ private: false makes a repository public |
| 288 | await repoWithReadme("flip"); |
| 289 | const flipped = await ok("PATCH", `/repos/${OWNER}/flip`, { private: false }); |
| 290 | assert.equal(flipped.private, false); |
| 291 | assert.equal(flipped.visibility, "public"); |
| 292 | await status(200, "GET", `/repos/${OWNER}/flip`, undefined, null); |
| 293 | await status(422, "PATCH", `/repos/${OWNER}/flip`, { visibility: "secret" }); |
| 294 | |
| 295 | // ------------------------------------------------------------ draft releases never reach readers' live pages |
| 296 | const ws = new WebSocket(`${base.replace("http", "ws")}/${OWNER}/flip/live?topics=repo,releases`); |
| 297 | const seen = []; |
| 298 | ws.addEventListener("message", (e) => seen.push(String(e.data))); |
| 299 | await new Promise((res, rej) => { |
| 300 | ws.addEventListener("open", res); |
| 301 | ws.addEventListener("error", rej); |
| 302 | }); |
| 303 | const draft = await ok("POST", `/repos/${OWNER}/flip/releases`, { tag_name: "v0.9", name: "Secret plans", body: "draft notes", draft: true }); |
| 304 | await ok("POST", `/repos/${OWNER}/flip/releases`, { tag_name: "v1.0", name: "Public release" }); |
| 305 | await until("the published release event", async () => seen.some((m) => m.includes("Public release"))); |
| 306 | ws.close(); |
| 307 | assert.ok(!seen.some((m) => m.includes("Secret plans") || m.includes(`"id":${draft.id}`)), `draft leaked: ${seen.join("\n")}`); |
| 308 | }); |
| 309 | |
| 310 | test("security: organization admins can't hand out ownership", async () => { |
| 311 | // rita is an admin of guild, olive its owner. Invitations need an admin-scoped token. |
| 312 | await status(403, "POST", "/orgs/guild/invitations", { email: "helper@example.com", role: "member" }, T.rita); |
| 313 | await status(403, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.ritaAdmin); |
| 314 | await status(422, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "superuser" }, T.ritaAdmin); |
| 315 | await status(201, "POST", "/orgs/guild/invitations", { email: "helper@example.com", role: "admin" }, T.ritaAdmin); |
| 316 | await status(201, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.olive); |
| 317 | }); |
| 318 | |
| 319 | /** Open a pull request from a new branch with one changed file. */ |
| 320 | async function pullFrom(R, base, branch, opts = {}) { |
| 321 | await ok("POST", `${R}/git/refs`, { ref: `refs/heads/${branch}`, sha: base }); |
| 322 | await ok("PUT", `${R}/contents/${branch}.txt`, { message: branch, content: b64(`${branch}\n`), branch }); |
| 323 | return ok("POST", `${R}/pulls`, { title: branch, head: branch, base: "main", ...opts }); |
| 324 | } |
| 325 | |
| 326 | test("security: anonymous writes, token scopes, agent secrets, merge rules and drafts", async () => { |
| 327 | const R = `/repos/${OWNER}/commons`; |
| 328 | const mainSha = await repoWithReadme("commons", { visibility: "public" }); |
| 329 | const issue = await ok("POST", `${R}/issues`, { title: "public issue" }); |
| 330 | |
| 331 | // ------------------------------------------------------------ unknown API paths answer like GitHub, in JSON |
| 332 | const missing = await status(404, "GET", "/no/such/endpoint"); |
| 333 | assert.equal(missing.message, "Not Found"); |
| 334 | |
| 335 | // ------------------------------------------------------------ anonymous callers read public repos but never write |
| 336 | await status(200, "GET", `${R}/issues/${issue.number}`, undefined, null); |
| 337 | await status(401, "POST", `${R}/issues`, { title: "spam" }, null); |
| 338 | await status(401, "POST", `${R}/issues/${issue.number}/comments`, { body: "spam" }, null); |
| 339 | await status(401, "POST", `${R}/issues/${issue.number}/reactions`, { content: "+1" }, null); |
| 340 | await status(401, "PUT", `${R}/subscription`, { subscribed: true }, null); |
| 341 | // ...while anonymous git fetches (a POST) keep working. |
| 342 | const clone = join(work, "commons-anon"); |
| 343 | execFileSync("git", ["-c", "protocol.version=2", "clone", "-q", `${base}/${OWNER}/commons.git`, clone], { stdio: "pipe" }); |
| 344 | |
| 345 | // ------------------------------------------------------------ read-scoped tokens only read |
| 346 | await status(200, "GET", R, undefined, T.reader); |
| 347 | await status(403, "POST", `${R}/issues`, { title: "nope" }, T.reader); |
| 348 | await status(403, "POST", `${R}/issues/${issue.number}/comments`, { body: "nope" }, T.reader); |
| 349 | await status(403, "POST", "/user/orgs", { login: "reader-org" }, T.reader); |
| 350 | await status(403, "POST", "/user/repos", { name: "reader-repo" }, T.reader); |
| 351 | await status(403, "POST", "/diffinery/agents", { login: "reader-bot" }, T.reader); |
| 352 | await status(403, "POST", "/diffinery/tokens", { name: "escalate", scopes: ["admin"] }, T.reader); |
| 353 | await status(403, "PUT", "/notifications", {}, T.reader); |
| 354 | // Token scopes are checked on the server. |
| 355 | await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: ["root"] }); |
| 356 | await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: [] }); |
| 357 | await status(422, "POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["superuser"] }); |
| 358 | const minted = await ok("POST", "/diffinery/tokens", { name: "fine", scopes: ["read", "read"] }); |
| 359 | assert.ok(minted.token); |
| 360 | assert.deepEqual((await ok("GET", "/diffinery/tokens")).find((t) => t.id === minted.id).scopes, ["read"]); |
| 361 | |
| 362 | // ------------------------------------------------------------ admin-role agents can't change secrets or variables |
| 363 | await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" }); |
| 364 | await status(204, "PUT", `${R}/actions/secrets/TOKEN`, { value: "s3cret" }); |
| 365 | await status(204, "PUT", `${R}/actions/variables/REGION`, { value: "eu" }); |
| 366 | await status(403, "DELETE", `${R}/actions/secrets/TOKEN`, undefined, T.bot); |
| 367 | await status(403, "PUT", `${R}/actions/variables/REGION`, { value: "attacker" }, T.bot); |
| 368 | await status(403, "DELETE", `${R}/actions/variables/REGION`, undefined, T.bot); |
| 369 | await status(403, "POST", `${R}/actions/variables`, { name: "NEW", value: "x" }, T.bot); |
| 370 | await status(403, "PATCH", `${R}/actions/variables/REGION`, { value: "attacker" }, T.bot); |
| 371 | assert.equal((await ok("GET", `${R}/actions/variables/REGION`)).value, "eu"); |
| 372 | |
| 373 | // ------------------------------------------------------------ the exact rule wins over a wildcard, whatever their order |
| 374 | await ok("PUT", `${R}/branches/${encodeURIComponent("*")}/protection`, { requireLinearHistory: true, requiredApprovals: 2 }); |
| 375 | await ok("PUT", `${R}/branches/main/protection`, { requireLinearHistory: true }); |
| 376 | const linear = await pullFrom(R, mainSha, "linear"); |
| 377 | // A merge commit breaks linear history, through the API as through the queue. |
| 378 | const refused = await status(405, "PUT", `${R}/pulls/${linear.number}/merge`, { merge_method: "merge" }); |
| 379 | assert.match(refused.message, /linear history/); |
| 380 | const squashed = await ok("PUT", `${R}/pulls/${linear.number}/merge`, { merge_method: "squash" }); |
| 381 | assert.equal(squashed.merged, true); |
| 382 | |
| 383 | // ------------------------------------------------------------ admins bypass unmet rules only when they ask to |
| 384 | await ok("PUT", `${R}/branches/main/protection`, { requiredApprovals: 1 }); |
| 385 | const head = (await ok("GET", `${R}/branches/main`)).commit.sha; |
| 386 | const unreviewed = await pullFrom(R, head, "unreviewed"); |
| 387 | const blocked = await status(405, "PUT", `${R}/pulls/${unreviewed.number}/merge`, { merge_method: "squash" }); |
| 388 | assert.match(blocked.message, /approving review/); |
| 389 | assert.match(blocked.message, /bypass_rules/); |
| 390 | assert.equal((await ok("PUT", `${R}/pulls/${unreviewed.number}/merge`, { merge_method: "squash", bypass_rules: true })).merged, true); |
| 391 | |
| 392 | // ------------------------------------------------------------ drafts can be marked ready for review |
| 393 | await status(204, "DELETE", `${R}/branches/main/protection`); |
| 394 | await status(204, "DELETE", `${R}/branches/${encodeURIComponent("*")}/protection`); |
| 395 | const head2 = (await ok("GET", `${R}/branches/main`)).commit.sha; |
| 396 | const draft = await pullFrom(R, head2, "wip", { draft: true }); |
| 397 | assert.equal(draft.draft, true); |
| 398 | await status(405, "PUT", `${R}/pulls/${draft.number}/merge`, { merge_method: "squash" }); |
| 399 | await status(403, "PATCH", `${R}/pulls/${draft.number}`, { draft: false }, T.rita); |
| 400 | const ready = await ok("PATCH", `${R}/pulls/${draft.number}`, { draft: false }); |
| 401 | assert.equal(ready.draft, false); |
| 402 | const events = await ok("GET", `${R}/issues/${draft.number}/events`); |
| 403 | assert.ok(events.some((e) => e.event === "ready_for_review"), JSON.stringify(events)); |
| 404 | assert.equal((await ok("PUT", `${R}/pulls/${draft.number}/merge`, { merge_method: "squash" })).merged, true); |
| 405 | }); |