Files
mesh-catalog/modules/gitea/test/guard.test.ts
T
jschoubben 5053e96d4e
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
Route a recheck's pending through the gitea module's one writer of mesh/merge-gate
The recheck's pending was written from the tools' bundle, its own process, past the
writer's per-head chain: the first run's verdict mark stayed set, so a rechecked head
never said where its new run builds, and an old run's verdict in flight or redelivered
could land over the pending. The status tool is now served from the watcher's bundle
and writes the merge check's pending through the writer, and a verdict whose ask
(checked.id) predates the check now pending is dropped, weighed after a restart against
the forge's own history of the gate (novox/hq issue 456).
2026-10-12 12:20:23 +02:00

393 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 { commitStatusTool } from "../dist/delivery.js";
import { GateWriter } from "../dist/gate.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 client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t"));
// gitea_commit_status is served from the watcher's bundle, beside the merge check's one writer (novox/hq issue 456).
const tools = [...getGiteaTools(client), commitStatusTool({
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
gates: new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
gateOf: async () => undefined, gateHistory: async () => [] }),
})];
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"]);
});