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.

405 lines · 24.8 KBRaw History
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.
16import { spawn, execFileSync } from "node:child_process";
17import { createHash, randomBytes } from "node:crypto";
18import { mkdtempSync, rmSync } from "node:fs";
19import { tmpdir } from "node:os";
20import { join, dirname, resolve } from "node:path";
21import { fileURLToPath } from "node:url";
22import { createServer } from "node:net";
23import { test, before, after } from "node:test";
24import assert from "node:assert/strict";
25
26const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
27const work = mkdtempSync(join(tmpdir(), "diffinery-security-"));
28let base;
29let dev;
30let devLog = "";
31
32const hash = (s) => createHash("sha256").update(s).digest("hex");
33const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
34const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin") };
35const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}` };
36const OWNER = "olive";
37
38const 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
47const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48async 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). */
59async 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}
73async 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}
78async 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}
83const b64 = (s) => Buffer.from(s).toString("base64");
84
85before(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
139after(() => {
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. */
149async 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
155test("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
310test("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. */
320async 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
326test("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});