mesh/delivery superseded: a newer head of the same pull request
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)
The review of #193 approved it with five nits, all taken. Seven characters of a sha can be matched by a commit made to match them in seconds, so an author who force-pushes could have a review merge a commit nobody read. A head is now named by at least 12 characters, up to 64 for a sha-256 repository. What landed was read from the forge's record of the pull request, which is the very thing a race leaves behind. It is now read from the merge commit: its second parent for a merge, the commit itself for a fast-forward. A squash or a rebase makes new commits that name no head, and the answer says it cannot be read back instead of staying silent. A merge of another head than the one handed over was reported as a field beside merged: true, which a caller reading only that would miss. It is now an error that says the merge happened and what landed. The refusal no longer asserts that the head moved after the review, which it cannot know; it says the head is not the one named.
347 lines
22 KiB
TypeScript
347 lines
22 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: "",
|
|
};
|
|
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";
|
|
});
|
|
|
|
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}`) {
|
|
return json(res, 200, { sha: state.mergeCommit,
|
|
parents: [{ sha: "8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b" }, { sha: state.landed || state.head }] });
|
|
}
|
|
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");
|
|
});
|
|
|
|
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
|
|
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);
|
|
|
|
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"]);
|
|
});
|