Files
mesh-catalog/modules/gitea/test/guard.test.ts
T
jschoubben c87a4421b9
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
gitea: a read-back that fails after the merge says the merge happened (review of issue 381)
The second review found the gap it was asked to look for: everything
after the forge's merge could throw — a forge 5xx, a timeout, a commit
named without parents — and the caller got a bare error that never said
the merge had happened.

From the merge on, a read-back that fails or cannot be done is said
beside merged: true, in read_back; only a proven other head is an error,
and that error says the merge happened. A parent named without its sha
is a failed read-back, never an empty head said to have landed.

A fast-forward's commit is taken as the head only when the forge names
it so. That it does is assumed, not seen on this forge, so another sha
is said in read_back rather than raised as another head having landed.

The method's description lists all five methods and says which two can
be read back. The fast-forward test now gives the merge commit a
different second parent, so reading it as a merge would fail there.
2026-10-11 01:35:43 +02:00

385 lines
24 KiB
TypeScript

// What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools
// with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from
// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the
// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing
// sent; the operator-approved merge of a pull request whose checks passed still goes through.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
interface Sent { method: string; path: string; body: any }
const state = {
sent: [] as Sent[],
asked: [] as string[],
statuses: [] as { context: string; state: string }[],
rules: [] as any[],
lastCommit: "2026-01-01T00:00:00Z",
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
// merge commit's own sha.
head: "",
landed: "",
merged: false,
mergeCommit: "",
// When set, the merge commit's parents as the forge reports them, or the status it answers them with.
parents: null as string[] | null,
commitsFail: 0,
};
const REVIEWED = "abc1234def5678901234567890abcdef12345678";
const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true });
beforeEach(() => {
state.sent = [];
state.asked = [];
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
state.rules = [protectedMain()];
state.head = REVIEWED;
state.landed = "";
state.merged = false;
state.mergeCommit = "feedface00000000000000000000000000000000";
state.parents = null;
state.commitsFail = 0;
});
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const read = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const path = url.pathname.replace(/^\/api\/v1/, "");
const method = req.method ?? "GET";
const body = await read(req);
state.asked.push(`${method} ${path}${url.search}`);
if (method !== "GET") state.sent.push({ method, path, body });
const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
if (method === "GET" && path === "/user") return json(res, 200, { login: "mesh-admin" });
if (method === "GET" && path === "/repos/mesh-admin/scratch/branch_protections") return json(res, 200, []);
if (method === "GET" && /^\/repos\/[^/]+\/[^/]+\/commits$/.test(path)) {
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
}
if (method === "GET" && path === `${repo}/pulls/7`) {
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
merge_commit_sha: state.merged ? state.mergeCommit : null });
}
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
if (state.commitsFail) return json(res, state.commitsFail, { message: "the forge is not well" });
const parents = state.parents ?? ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", state.landed || state.head];
return json(res, 200, { sha: state.mergeCommit, parents: parents.map((sha) => ({ sha })) });
}
if (method === "GET" && path === `${repo}/commits/${state.head}/status`) {
return json(res, 200, { sha: state.head, state: "success", statuses: state.statuses });
}
if (method === "POST" && path === `${repo}/pulls/7/merge`) {
state.merged = true;
return json(res, 200, null);
}
return json(res, method === "GET" ? 200 : 201, {});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const tools = getGiteaTools(new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")));
const tool = (name: string) => tools.find((t) => t.name === name)!;
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
test("gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing", async () => {
for (const [method, path, body] of [
["PATCH", "/admin/users/agent", { admin: true }],
["DELETE", "/repos/novox/mesh-catalog/branch_protections/main", undefined],
["PATCH", "/repos/novox/mesh-catalog/branch_protections/main", { enable_status_check: false }],
["POST", "/repos/novox/mesh-catalog/statuses/abc1234", { context: "mesh/merge-gate", state: "success" }],
["POST", "/repos/novox/mesh-catalog/pulls/7/merge", { Do: "merge", force_merge: true }],
["PUT", "/repos/novox/mesh-catalog/collaborators/agent", { permission: "admin" }],
["POST", "/repos/novox/mesh-catalog/hooks", { type: "gitea" }],
["POST", "/repos/novox/mesh-catalog/keys", { key: "ssh-ed25519 AAAA" }],
["PATCH", "/repos/novox/mesh-catalog", { default_branch: "agent" }],
["DELETE", "/repos/novox/mesh-catalog", undefined],
["POST", "/repos/novox/mesh-catalog/transfer", { new_owner: "agent" }],
["POST", "/users/mesh-admin/tokens", { name: "mine", scopes: ["all"] }],
["PUT", "/orgs/novox/members/agent", undefined],
["PATCH", "/repos/novox/mesh-catalog/issues/1/../../branch_protections/main", { enable_push: true }],
["PATCH", "/repos/novox/mesh-catalog/issues/1/%2e%2e/%2e%2e/branch_protections/main", { enable_push: true }],
] as const) {
await assert.rejects(tool("gitea_api").run({ path, method, ...(body ? { body } : {}) }),
/not a write gitea_api makes|not taken|administration/, `${method} ${path}`);
}
await assert.rejects(tool("gitea_api").run({ path: "/admin/users" }), /administration/);
assert.deepEqual(writes(), []);
});
test("gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own", async () => {
const put = (body: Record<string, unknown>) =>
tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/contents/modules/sudo/module.json", method: "PUT", body });
await assert.rejects(put({ content: "e30=", sha: "x" }), /default branch/);
await assert.rejects(put({ content: "e30=", sha: "x", branch: "main" }), /default branch/);
state.rules.push({ rule_name: "release/*" });
await assert.rejects(put({ content: "e30=", sha: "x", branch: "release/1" }), /protection rule release\/\*/);
assert.deepEqual(writes(), []);
await put({ content: "e30=", sha: "x", branch: "main", new_branch: "feat/sudo-docs" });
assert.deepEqual(writes(), ["PUT /repos/novox/mesh-catalog/contents/modules/sudo/module.json"]);
});
test("gitea_api still writes what an author writes", async () => {
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("gitea_branch_protection_set only strengthens", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }),
/merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
});
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED });
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "failure" }];
await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/);
state.statuses = [{ context: "mesh/merge-gate", state: "success" }];
await assert.rejects(merge(), /mesh\/repo-check \(missing\)/);
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, method: "manually-merged" }), /not a merge method/);
assert.deepEqual(writes(), []);
// The operator said yes and the checks passed: the flow that stays.
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
await merge();
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
assert.equal(sent.body.force_merge, false);
assert.equal(sent.body.head_commit_id, REVIEWED);
});
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
const merge = (head?: string) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
...(head === undefined ? {} : { head }) });
// No head named: there is no review to hold the merge to.
await assert.rejects(merge(), /name the head you reviewed/);
await assert.rejects(merge(" "), /name the head you reviewed/);
await assert.rejects(merge("main"), /does not name one commit/);
// Seven characters can be matched by a commit made to match them: twelve are asked for.
await assert.rejects(merge(REVIEWED.slice(0, 7)), /does not name one commit — give its sha, or at least its first 12/);
await assert.rejects(merge(REVIEWED.slice(0, 11)), /does not name one commit/);
// The head moved after the review, and its checks passed where it is now: the case of mesh-controller#197.
state.head = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
await assert.rejects(merge(REVIEWED), /its head is 3ced96fc6f8a, not the abc1234def5678901234567890abcdef12345678 you named/);
await assert.rejects(merge(REVIEWED.slice(0, 12)), /not the abc1234def56 you named\. If you reviewed an earlier head/);
assert.deepEqual(writes(), [], "a refused merge sends nothing");
// Named as reviewed, by its first 12 characters, in either case: the forge is handed the whole head, and the merge
// commit's second parent says that head landed.
state.head = REVIEWED;
const done = (await merge(REVIEWED.slice(0, 12).toUpperCase())) as Record<string, unknown>;
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
assert.equal(sent.body.head_commit_id, REVIEWED);
assert.equal(done.head, REVIEWED);
assert.equal(done.landed, REVIEWED);
assert.equal(done.read_back, undefined);
});
// The forge is handed the head, and what landed is read from the merge commit, not from the forge's record of the
// pull request (which here still says the head it was handed). Another head landing is an error that says the merge
// happened, never a field beside merged: true.
test("gitea_merge_pull_request is loud when the forge merged a head other than the one it was handed", async () => {
state.landed = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED }), /WAS merged \(feedface0000.*\), but the forge merged 3ced96fc6f8a, not the abc1234def56 it was handed/);
assert.equal(state.merged, true, "the merge happened: the error is what says so");
});
// The merge has happened once the forge says so: a read-back that fails after it must not read as nothing merged.
test("gitea_merge_pull_request says the merge happened when reading back what landed fails", async () => {
state.commitsFail = 500;
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED })) as Record<string, unknown>;
assert.equal(state.merged, true);
assert.equal(done.merged, true);
assert.equal(done.landed, undefined);
assert.match(String(done.read_back), /the merge happened, but reading back what landed failed/);
// A parent the forge names without its sha is a failed read-back, never an empty head said to have landed.
state.merged = false;
state.commitsFail = 0;
state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", ""];
const blank = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED })) as Record<string, unknown>;
assert.equal(blank.merged, true);
assert.match(String(blank.read_back), /without its sha/);
});
test("gitea_merge_pull_request reads back a fast-forward, and says it cannot read back a squash or a rebase", async () => {
state.mergeCommit = REVIEWED; // a fast-forward lands the head itself
// Its parents name another commit second, so reading a fast-forward as a merge's second parent would fail here.
state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b"];
const ff = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, method: "fast-forward-only" })) as Record<string, unknown>;
assert.equal(ff.landed, REVIEWED);
assert.equal(ff.read_back, undefined);
// A fast-forward whose commit the forge names otherwise is said, not raised: that it names the head is assumed.
state.merged = false;
state.mergeCommit = "feedface00000000000000000000000000000000";
const odd = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, method: "fast-forward-only" })) as Record<string, unknown>;
assert.equal(odd.merged, true);
assert.equal(odd.landed, undefined);
assert.match(String(odd.read_back), /as this fast-forward's commit, not the head/);
for (const method of ["squash", "rebase", "rebase-merge"]) {
state.merged = false;
state.mergeCommit = "feedface00000000000000000000000000000000";
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
head: REVIEWED, method })) as Record<string, unknown>;
assert.equal(done.landed, undefined, method);
assert.match(String(done.read_back), new RegExp(`a ${method} makes new commits that name no head`));
}
});
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
state.lastCommit = new Date().toISOString();
await assert.rejects(del("mesh-admin", "scratch", "mesh-admin/scratch"), /within 30 days/);
assert.deepEqual(writes(), []);
state.lastCommit = "2026-01-01T00:00:00Z";
await del("mesh-admin", "scratch", "mesh-admin/scratch");
assert.deepEqual(writes(), ["DELETE /repos/mesh-admin/scratch"]);
});
test("gitea_api sends the path it judged, and refuses moving a pull request's base", async () => {
await tool("gitea_api").run({ path: "/api/v1/repos/novox/mesh-catalog/issues/3/comments/?page=1", method: "POST", body: { body: "hi" } });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments"]);
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH",
body: { base: "unguarded" } }), /changing a pull request's base/);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", body: { title: "x" } });
assert.equal(writes().length, 2);
});
test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => {
state.rules = [];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
/no protection rule requiring the checks guards/);
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
/no protection rule requiring the checks guards/);
assert.deepEqual(writes(), []);
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate", block_admin_override: true });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
});
test("a repository owner or name is never another path", async () => {
await assert.rejects(tool("gitea_update_pull_request").run({ owner: "novox", repo: "mesh-catalog/branch_protections/main?",
number: 1, title: "x" }), /not a repository name/);
await assert.rejects(tool("gitea_get_issue").run({ owner: "..", repo: "admin", number: 1 }), /not a repository owner/);
assert.deepEqual(writes(), []);
});
test("the judges, without a forge", () => {
assert.equal(routedPath("/api/v1/repos/a/b/issues?x=1"), "/repos/a/b/issues");
assert.throws(() => routedPath("/repos/a/b/../c"), /dot segment/);
assert.ok("allowed" in judgeApiCall("GET", "/repos/a/b/branch_protections", undefined));
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null);
assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null);
});
test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => {
for (const path of ["/user?sudo=jschoubben", "/user?SUDO=jschoubben", "/repos/novox/mesh-catalog/issues?state=open&Sudo=jschoubben",
"/repos/novox/mesh-catalog/issues?token=abc", "/repos/novox/mesh-catalog/issues?access_token=abc"]) {
await assert.rejects(tool("gitea_api").run({ path }), /query key/, path);
}
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments?sudo=jschoubben",
method: "POST", body: { body: "as the operator" } }), /query key sudo/);
assert.deepEqual(state.asked, []);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues?state=open&limit=5" });
assert.deepEqual(state.asked, ["GET /repos/novox/mesh-catalog/issues?state=open&limit=5"]);
});
test("gitea_api never approves a pull request nor requests changes: those are the operator's", async () => {
for (const [path, event] of [["/repos/novox/mesh-catalog/pulls/7/reviews", "APPROVE"],
["/repos/novox/mesh-catalog/pulls/7/reviews", "approve"], ["/repos/novox/mesh-catalog/pulls/7/reviews", "REQUEST_CHANGES"],
["/repos/novox/mesh-catalog/pulls/7/reviews/5", "APPROVE"]]) {
await assert.rejects(tool("gitea_api").run({ path, method: "POST", body: { event, body: "lgtm" } }),
/operator's at the forge/, `${path} ${event}`);
}
assert.deepEqual(writes(), []);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT", body: "a note" } });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge", async () => {
const repo = { owner: "novox", repo: "mesh-catalog" };
const cases: Array<[string, Record<string, unknown>]> = [
["gitea_delete_branch", { ...repo, branch: ".." }],
["gitea_delete_branch", { ...repo, branch: "feat/../.." }],
["gitea_delete_branch", { ...repo, branch: "x?sudo=jschoubben" }],
["gitea_delete_branch", { ...repo, branch: "%2e%2e" }],
["gitea_get_file", { ...repo, path: "../../../../version" }],
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
["gitea_commit_statuses", { ...repo, sha: ".." }],
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
["gitea_branch_protection_set", { ...repo, branch: "main?sudo=jschoubben", status_checks: "mesh/merge-gate" }],
["gitea_contains", { ...repo, commit: "..", in: "abc1234" }],
];
for (const [name, args] of cases) {
state.asked = [];
await assert.rejects(tool(name).run(args), (err: unknown) => err instanceof Error, `${name} ${JSON.stringify(args)}`);
// Nothing that is not a read of the repository's own rules reached the forge.
assert.deepEqual(state.asked.filter((a) => !a.startsWith("GET /repos/novox/mesh-catalog/branch_protections")), [],
`${name} ${JSON.stringify(args)} reached the forge`);
}
// What is ordinary still goes: a branch with a slash, a file in a directory.
state.asked = [];
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
});