Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+130 -4
View File
@@ -47,11 +47,18 @@ test("every open pull request is read with what its base requires and what its h
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
}
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
// The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it.
if (url.includes("/branch_protections")) {
res.statusCode = 403;
return res.end(JSON.stringify({ message: "the rules are a repository administrator's" }));
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) {
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
status_check_contexts: ["mesh/repo-check"] });
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) {
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] });
}
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
@@ -137,3 +144,122 @@ test("a merged pull request whose branch was deleted is announced with its branc
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
assert.equal(headBranchOf(undefined), undefined);
});
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
// newest comment "ready at <full head sha>" by a listed account, written after the label was added and after the last
// push. Read from the pull request's whole timeline, page by page; without the label nothing is read.
const head = "c".repeat(40), older = "d".repeat(40);
const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) =>
({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`,
...(label ? { label } : {}) });
const readyLabel = { id: 31, name: "mesh/ready" };
function forgeWith(timeline: unknown[]) {
const asked: string[] = [];
const server = createServer((req, res) => {
const url = req.url ?? "";
asked.push(url);
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
}
const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url);
if (t) {
const page = Number(t[1]);
return send(timeline.slice((page - 1) * 50, page * 50));
}
if (url.includes("/branch_protections")) return send([]);
if (url.includes("/status")) return send({ statuses: [] });
res.statusCode = 404;
res.end("{}");
});
return { server, asked };
}
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
const { GiteaClient } = await import("../client.ts");
const { server, asked } = forgeWith(timeline);
await new Promise<void>((r) => server.listen(0, r));
try {
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
return { open: await client.openPulls(authors), asked };
} finally {
server.close();
}
}
test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => {
const { open, asked } = await readOpen([
ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })),
ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`),
ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`),
ev(5, "comment", "thanks", "jschoubben"),
]);
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 });
assert.equal(open[1].ready, undefined, "no label, no mark");
assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label");
});
test("a ready comment by an account not listed is ignored, and said", async () => {
const { readyMarkOf } = await import("../client.ts");
const said: string[] = [];
const mark = readyMarkOf(31, [
ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any,
{ ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" },
].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l));
assert.equal(mark.head, "", "an outsider's mark counts");
assert.equal(said.length, 1);
assert.match(said[0], /by outsider is ignored/);
});
test("a ready comment older than the latest push, or than the label, never counts", async () => {
const pushedAfter = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })),
]);
assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on");
const beforeLabel = await readOpen([
ev(1, "pull_push", "{}"),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "1", "mesh-admin", readyLabel),
]);
assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts");
const takenOff = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "", "mesh-admin", readyLabel),
]);
assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts");
});
test("a long thread hides nothing: every page of the timeline is read", async () => {
const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`)];
for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben"));
events.push(ev(130, "comment", `ready at ${head}`, "jschoubben"));
const { open, asked } = await readOpen(events);
assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read");
assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3);
});
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
const { readyAuthors } = await import("../client.ts");
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
// The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469).
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
});
+98 -2
View File
@@ -30,6 +30,9 @@ const state = {
// What the tools said on the bus.
emitted: [] as { type: string; body: any }[],
rules: [] as any[],
// Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
admin: false,
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
@@ -41,8 +44,13 @@ const state = {
// 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,
// The sibling beside which a verdict was judged (novox/hq ADR 0382 §6): its group status at its head, and its reviews.
groupStatus: "",
siblingGroup: "",
reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[],
};
const REVIEWED = "abc1234def5678901234567890abcdef12345678";
const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40);
const TRUNK = "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b";
const MOVED = "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d";
// A verdict judged against a trunk commit, as both statuses say it (novox/hq ADR 0323 §3).
@@ -59,12 +67,16 @@ beforeEach(() => {
state.trunk = TRUNK;
state.emitted = [];
state.rules = [protectedMain()];
state.admin = false;
state.head = REVIEWED;
state.landed = "";
state.merged = false;
state.mergeCommit = "feedface00000000000000000000000000000000";
state.parents = null;
state.commitsFail = 0;
state.groupStatus = "";
state.siblingGroup = "";
state.reviews = [];
});
const json = (res: ServerResponse, status: number, body: unknown): void => {
@@ -87,8 +99,18 @@ const server = createServer(async (req, res) => {
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 (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
}
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } });
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
if (branch !== null) {
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
}
if (method === "GET" && path === `${repo}/pulls/7/files`) {
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
}
@@ -113,8 +135,17 @@ const server = createServer(async (req, res) => {
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 });
const group = state.groupStatus ? [{ context: "mesh/delivery-group", status: state.groupStatus }] : [];
return json(res, 200, { sha: state.head, state: "success", statuses: [...state.statuses, ...group] });
}
if (method === "GET" && path === `/repos/novox/mesh-host/commits/${SIBLING_HEAD}/status`) {
return json(res, 200, { sha: SIBLING_HEAD, state: "success",
statuses: state.siblingGroup ? [{ context: "mesh/delivery-group", status: state.siblingGroup }] : [] });
}
if (method === "GET" && path === "/repos/novox/mesh-host/pulls/114/reviews") {
return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []);
}
if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []);
if (method === "POST" && path === `${repo}/pulls/7/merge`) {
state.merged = true;
return json(res, 200, null);
@@ -199,6 +230,7 @@ test("a protection rule covers a branch as the forge decides it: classes, altern
});
test("gitea_api still writes what an author writes", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
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" });
@@ -206,6 +238,7 @@ test("gitea_api still writes what an author writes", async () => {
});
test("gitea_branch_protection_set only strengthens", async () => {
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
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/);
@@ -396,6 +429,7 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea
});
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
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/);
@@ -427,6 +461,7 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
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>) =>
@@ -511,3 +546,64 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
});
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6, issue 867):
// the verdict names the sibling; mesh/delivery-group must be a success at the member's head and the sibling's head that
// verdict read, and the sibling's head must carry an approving review. A verdict that read none merges as before.
test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each sibling head reviewed", async () => {
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${SIBLING_HEAD.slice(0, 8)}`;
const named = (siblings: unknown[]) => keepSiblings(siblingsFile(stateDir),
{ "novox/mesh-catalog#7": { head: REVIEWED, siblings: siblings as any } });
state.statuses = [{ context: "mesh/merge-gate", state: "success", description: `${beside} — pass: x` },
{ context: "mesh/repo-check", state: "success", description: `${beside} — pass: y` }];
named([{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: "c".repeat(40) }]);
// Nothing passed: every missing thing is named, nothing merged.
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/);
return true;
});
assert.equal(state.merged, false);
// The group passed at the member's head only: the sibling's head still lacks it.
state.groupStatus = "success";
await assert.rejects(merge(), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/);
// Passed at both heads, but the only approval is at another head, or dismissed: not an approval at the head read.
state.siblingGroup = "success";
state.reviews = [{ state: "APPROVED", commit_id: "d".repeat(40) }, { state: "APPROVED", commit_id: SIBLING_HEAD, dismissed: true },
{ state: "COMMENT", commit_id: SIBLING_HEAD }];
await assert.rejects(merge(), /judged beside 1 sibling\(s\), and mesh-host#114@bbbbbbbbbbbb carries no approving review at that head\. A member judged beside/);
assert.equal(state.merged, false);
// Passed and approved at the very heads read: it merges.
state.reviews.push({ state: "APPROVED", commit_id: SIBLING_HEAD });
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
// A status that says it read siblings, with no record of them, is no verdict to merge on.
state.merged = false;
keepSiblings(siblingsFile(stateDir), {});
await assert.rejects(merge(), /its verdict read siblings, and which ones is not held here/);
assert.equal(state.merged, false);
// A verdict that read none (an older controller, or a group of one) merges exactly as before.
state.statuses = judgedAt(TRUNK);
state.groupStatus = "";
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
test("a verdict put out of date by a sibling's move is refused in its own words, and asked again once", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const words = "out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged";
state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending", description: words }));
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /its verdict was put out of date by a sibling's move \(out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd/);
assert.match(err.message, /was asked/);
assert.match(err.message, /never merges by itself/);
return true;
});
assert.equal(state.merged, false);
assert.equal(state.emitted.length, 1, "the check is asked, once");
});
@@ -0,0 +1,59 @@
// The controller's build-source check asks gitea_branch_protection_get for a repository's rules, which the forge
// answers to a repository's administrator alone. The tools' account is not one (novox/hq ADR 0365 §2), so the read
// goes with the forge's administrator account, and only the read (ADR 0375, issue 772): a protection edit through the
// tools still gets the forge's refusal.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
const rules = [
{ rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true },
{ branch_name: "release", enable_push: false },
];
const seen: string[] = [];
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const as = req.headers.authorization === adminBasic ? "admin" : "tools";
seen.push(`${req.method} ${path} as ${as}`);
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (path.includes("/branch_protections")) {
if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET") return send(200, rules);
}
send(404, { message: "not found" });
});
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 url = `http://127.0.0.1:${port}`;
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"));
const tool = (name: string) => tools.find((t) => t.name === name)!;
test("gitea_branch_protection_get answers every rule, read with the administrator account the tools' token cannot read with", async () => {
const out: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller" });
assert.deepEqual(out.rules.map((r: any) => r.rule), ["main", "release"]);
});
test("gitea_branch_protection_get answers the rule for a branch, null when it has none", async () => {
const main: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "main" });
assert.equal(main.branch, "main");
assert.equal(main.rule.rule, "main");
assert.equal(main.rule.admin_may_override, false);
const none: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "dev" });
assert.equal(none.rule, null);
});
test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => {
await assert.rejects(
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }),
/403/,
);
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
});
+269
View File
@@ -0,0 +1,269 @@
// **A delivery group's member is judged beside its siblings, and a sibling's move puts that verdict out of date**
// (novox/hq ADR 0382 §4-§6, issue 867): the holder keeps the siblings each verdict names; a sibling's new head, close
// unmerged or base move (other than its own merge of the head that was read) sets both statuses of every member whose
// verdict names it back to pending, and runs nothing; a member merges only with its group passed and every sibling head
// read approved.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
import { weighTrunks } from "../dist/trunk.js";
import { commentFor, statusesFor } from "../dist/pulls.js";
const h = (c: string) => c.repeat(40);
const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f");
const sibling = { repository: "novox/mesh-host", number: 114, head: HOST_HEAD, base: "main", base_commit: HOST_BASE };
const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, verdict: "pass", summary: "x", id: "build-1",
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => {
// A realistic controller summary for a failure, long enough that the forge clips it.
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change";
const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } };
const [gate, repo] = statusesFor(c);
for (const s of [gate, repo]) {
assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description);
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
assert.ok(namesSiblings(s.description));
}
assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description);
assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description);
assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more");
const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!;
assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/);
});
test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => {
const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" };
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims";
for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) {
assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description);
assert.ok(s.description.length <= 140);
assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none");
}
assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/);
});
test("a verdict without siblings is said exactly as before (an older controller)", () => {
const { siblings, judge_from, ...old } = verdict;
const [gate] = statusesFor(old);
assert.equal(namesSiblings(gate.description), false);
assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /);
assert.deepEqual(siblingsOf(undefined), []);
assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none");
});
test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => {
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
const record: SiblingRecord = {};
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false);
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true);
keepSiblings(file, record);
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true);
assert.deepEqual(record, {});
assert.deepEqual(readSiblings(null), {});
});
// --- a sibling's move -----------------------------------------------------------------------------------------
interface World {
host: { merged: boolean; state: string; head_sha: string; merge_commit_sha?: string };
hostBase: string;
parents: string[];
member: { head_sha: string; state: string; merged: boolean };
checks: Record<string, { state: string; description?: string }>;
written: { sha: string; context: string; state: string; description: string }[];
reads: string[];
}
const world = (): World => ({ host: { merged: false, state: "open", head_sha: HOST_HEAD }, hostBase: HOST_BASE, parents: [],
member: { head_sha: MEMBER_HEAD, state: "open", merged: false },
checks: { "mesh/merge-gate": { state: "success", description: "head aaaaaaaaaaaa merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — pass: x" },
"mesh/repo-check": { state: "success", description: "pass: y" } },
written: [], reads: [] });
const forgeOf = (w: World) => ({
async getPullRequest(owner: string, repo: string, n: number) {
w.reads.push(`${owner}/${repo}#${n}`);
return repo === "mesh-host" ? { number: n, title: "", html_url: "", ...w.host } : { number: n, title: "", html_url: "", merged: false, ...w.member };
},
async branchHead(_o: string, _r: string, _b: string) { return w.hostBase; },
async commitParents() { return w.parents; },
});
const gatesOf = (w: World) => ({
async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[] | Promise<any[]>) {
const put = await decide(w.checks);
for (const s of put) { w.written.push({ sha, ...s }); w.checks[s.context] = { state: s.state, description: s.description }; }
return put;
},
});
const recordOf = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
const weigh = (w: World, record: SiblingRecord, repository = "novox/mesh-host", number?: number) =>
weighSiblings(forgeOf(w) as any, gatesOf(w) as any, record, repository, number);
test("a sibling's new head puts both statuses of the member back to pending, with the reason, and runs nothing", async () => {
const w = world();
w.host.head_sha = HOST_NEW;
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.deepEqual(w.written.map((s) => [s.sha, s.context, s.state]),
[[MEMBER_HEAD, "mesh/merge-gate", "pending"], [MEMBER_HEAD, "mesh/repo-check", "pending"]]);
assert.equal(w.written[0].description,
"out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged");
assert.ok(siblingOutOfDate(w.written[0].description));
// Already pending (out of date, or a check on its way): not written again.
w.written.length = 0;
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.deepEqual(w.written, []);
});
test("a sibling closed unmerged puts the member's verdict out of date", async () => {
const w = world();
w.host.state = "closed";
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.equal(w.written.length, 2);
assert.match(w.written[0].description, /^out of date: judged beside mesh-host#114 was closed unmerged/);
});
test("a move of the sibling's base puts it out of date, naming both commits", async () => {
const w = world();
w.hostBase = HOST_MOVED;
await weigh(w, recordOf());
assert.equal(w.written.length, 2);
assert.match(w.written[0].description, /mesh-host#114 merged onto cccccccccccc, its main is now eeeeeeeeeeee/);
});
test("the sibling's own merge of the head read onto the base read does not, in either order of the two merges", async () => {
// The sibling merges first (a merge commit of the base commit read and the head read).
const w = world();
Object.assign(w.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
w.hostBase = MERGE;
w.parents = [HOST_BASE, HOST_HEAD];
const record = recordOf();
assert.equal(await weigh(w, record), true, "the record changes: the sibling is marked merged");
assert.deepEqual(w.written, []);
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
// A move of that base afterwards is no sibling's move: the sibling is merged.
w.hostBase = HOST_MOVED;
await weigh(w, record);
assert.deepEqual(w.written, []);
// The member merges first: its own repository moves, which no member's verdict names as a sibling.
const m = world();
m.member.merged = true;
await weigh(m, recordOf(), "novox/mesh-controller");
assert.deepEqual(m.written, []);
// And the sibling then merges: still not a move.
Object.assign(m.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
m.parents = [HOST_BASE, HOST_HEAD];
await weigh(m, recordOf());
assert.deepEqual(m.written, []);
// A squash is one parent: the base commit read.
const q = world();
Object.assign(q.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
q.parents = [HOST_BASE];
await weigh(q, recordOf());
assert.deepEqual(q.written, []);
});
test("a merge of another head, or onto another base, is a move", async () => {
const other = world();
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
other.parents = [HOST_BASE, HOST_NEW];
await weigh(other, recordOf());
assert.equal(other.written.length, 2);
const onto = world();
Object.assign(onto.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
onto.parents = [HOST_MOVED, HOST_HEAD];
await weigh(onto, recordOf());
assert.equal(onto.written.length, 2);
});
test("only the members whose verdict names the sibling are touched, and a member that moved on is dropped", async () => {
// Another sibling's move, or another repository's: nothing read, nothing written.
const w = world();
w.host.head_sha = HOST_NEW;
await weigh(w, recordOf(), "novox/mesh-lab");
await weigh(w, recordOf(), "novox/mesh-host", 999);
assert.deepEqual(w.written, []);
// A verdict that is none (a gate not yet judged) is left alone.
const none = world();
none.host.head_sha = HOST_NEW;
none.checks = {};
await weigh(none, recordOf());
assert.deepEqual(none.written, []);
// The member has a new head: its old verdict is no verdict; the record is dropped, nothing written to the old head.
const moved = world();
moved.host.head_sha = HOST_NEW;
moved.member.head_sha = h("9");
const record = recordOf();
assert.equal(await weigh(moved, record), true);
assert.deepEqual(moved.written, []);
assert.deepEqual(record, {});
// A repository without a repo-check gets no pending one a verdict would never set.
assert.deepEqual(siblingStatuses({ "mesh/merge-gate": { state: "success" } }, "x").map((s) => s.context), ["mesh/merge-gate"]);
});
test("a trunk that moved tells the siblings' weighing; one first seen does not", async () => {
const told: string[] = [];
const forge = { listOpenPulls: async () => [{ number: 1, title: "", state: "open", merged: false, head_sha: h("2"), base: "main", html_url: "" }],
branchHead: async () => h("3") };
const gates = { outOfDate: async () => [] };
const trunks: Record<string, string> = {};
await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); });
assert.deepEqual(told, [], "first seen");
trunks["novox/mesh-host:main"] = h("4");
await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); });
assert.deepEqual(told, ["main"]);
});
// --- the merge rule -------------------------------------------------------------------------------------------
test("the group rule names every missing thing: the group status at each head read, and each sibling head's review", async () => {
const self = { owner: "novox", repo: "mesh-controller", number: 346, head: MEMBER_HEAD };
const statuses: Record<string, string> = {};
const approved = new Set<string>();
const forge = {
headChecks: async (_o: string, r: string, sha: string) => (statuses[`${r}@${sha}`] ? { "mesh/delivery-group": { state: statuses[`${r}@${sha}`] } } : {}),
approvedAt: async (_o: string, r: string, n: number, head: string) => approved.has(`${r}#${n}@${head}`),
};
const siblings = siblingsOf([sibling]);
const none = await groupRefusal(forge, self, siblings);
assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/);
statuses[`mesh-controller@${MEMBER_HEAD}`] = "success";
statuses[`mesh-host@${HOST_HEAD}`] = "pending";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/);
statuses[`mesh-host@${HOST_HEAD}`] = "success";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh-host#114@bbbbbbbbbbbb carries no approving review at that head$/);
approved.add(`mesh-host#114@${HOST_HEAD}`);
assert.equal(await groupRefusal(forge, self, siblings), undefined);
// A read that fails is named, never read as a pass.
const broken = { ...forge, approvedAt: async () => { throw new Error("the forge is away"); } };
assert.match((await groupRefusal(broken, self, siblings))!, /could not be read \(the forge is away\)/);
assert.equal(besideWords(siblings), "beside mesh-host#114@bbbbbbbb");
});
test("a merged or closed member's entry is dropped, so a sibling's later push never re-pends its merged head", async () => {
// mesh-host#114 judged beside mesh-controller#346; #114 then merged; a push to #346 follows.
const w = world();
w.host.merged = true; // the member (mesh-host#114) is merged
w.member.head_sha = h("9"); // the sibling (mesh-controller#346) has a new head
const record: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: siblingsOf([
{ repository: "novox/mesh-controller", number: 346, head: MEMBER_HEAD, base: "main", base_commit: h("2") }]) } };
assert.equal(await weigh(w, record, "novox/mesh-controller", 346), true);
assert.deepEqual(w.written, [], "the merged head is not put back to pending");
assert.deepEqual(record, {}, "and its entry is gone");
// On its own close or merge the member's entry goes too, whatever the siblings do.
const own = world();
own.host.state = "closed";
const kept = recordOf();
const mine: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: kept["novox/mesh-controller#346"].siblings } };
assert.equal(await weigh(own, mine, "novox/mesh-host", 114), true);
assert.deepEqual(mine, {});
assert.deepEqual(own.written, []);
});
+116 -6
View File
@@ -10,7 +10,7 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -29,6 +29,12 @@ interface Forge {
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
/** Tokens beside `tokens` that carry a name already held there: what two mints at once leave behind. */
twins: { id: number; name: string; sha1: string }[];
/** How each DELETE named its token: "id" or "name". */
deletedBy: string[];
/** Plant a token by name, beside any other of the same name, as a raced mint would. */
plant(name: string): void;
scopesOf: Map<string, string[]>;
admins: Map<string, string>;
pullState: string;
@@ -39,9 +45,14 @@ interface Forge {
function fakeForge(): Promise<Forge> {
const forge = {
mintedFor: "",
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
twins: [] as { id: number; name: string; sha1: string }[],
deletedBy: [] as string[],
ids: new Map<string, number>(), // value -> id, as gitea numbers its tokens
nextId: 0,
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]),
pullState: "open",
@@ -73,7 +84,14 @@ function fakeForge(): Promise<Forge> {
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
// gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365).
if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" });
// This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the
// admin's own list is then empty — nothing of it is retired.
if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") {
return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" });
}
if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]);
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
@@ -82,12 +100,35 @@ function fakeForge(): Promise<Forge> {
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
forge.scopesOf.set(sha1, scopes);
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
forge.ids.set(sha1, ++forge.nextId);
return json(res, 201, { id: forge.ids.get(sha1), name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
// gitea's list: every token of the account, a page at a time (at most three to a page here, so a
// client that reads only the first page misses some), the values never shown.
const listed = (): { id: number; name: string; sha1: string }[] => [
...[...forge.tokens].map(([name, sha1]) => ({ id: forge.ids.get(sha1) ?? 0, name, sha1 })),
...forge.twins,
];
if (req.method === "GET" && !tokens[2]) {
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
const limit = Math.min(3, Math.max(1, Number(url.searchParams.get("limit") ?? "3")));
const all = listed();
res.setHeader("X-Total-Count", String(all.length));
return json(res, 200, all.slice((page - 1) * limit, page * limit)
.map((t) => ({ id: t.id, name: t.name, sha1: "", token_last_eight: t.sha1.slice(-8), scopes: [] })));
}
if (req.method === "DELETE" && tokens[2]) {
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
// gitea: "identified by ID and if not available by name" — and a name held twice is refused.
const key = decodeURIComponent(tokens[2]);
const byId = /^\d+$/.test(key);
const matches = listed().filter((t) => (byId ? String(t.id) === key : t.name === key));
if (matches.length === 0) return json(res, 404, { message: "token not found" });
if (matches.length > 1) return json(res, 422, { message: `multiple matches for token name '${key}'` });
const t = matches[0];
forge.deletedBy.push(byId ? "id" : "name");
const twin = forge.twins.findIndex((w) => w.id === t.id);
if (twin >= 0) forge.twins.splice(twin, 1);
else forge.tokens.delete(t.name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
@@ -153,6 +194,17 @@ function fakeForge(): Promise<Forge> {
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]);
}
// What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly.
if (basic(req) === ADMIN) {
const p = url.pathname;
if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true });
if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false });
if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" });
if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" });
if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]);
if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 });
if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null);
}
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
if (adminUser && req.method === "PATCH") {
const h = req.headers.authorization ?? "";
@@ -181,6 +233,14 @@ function fakeForge(): Promise<Forge> {
get pullTitle() { return forge.pullTitle; },
get branchDeleted() { return forge.branchDeleted; },
tokens: forge.tokens,
twins: forge.twins,
deletedBy: forge.deletedBy,
plant: (name: string) => {
const sha1 = `planted-${++forge.nextId}`;
forge.ids.set(sha1, forge.nextId);
if (forge.tokens.has(name)) forge.twins.push({ id: forge.nextId, name, sha1 });
else forge.tokens.set(name, sha1);
},
scopesOf: forge.scopesOf,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
@@ -335,6 +395,56 @@ test("concurrent first calls share one mint", async () => {
assert.equal(forge.mints, before + 1);
});
test("two tokens hold the name (two mints raced): both are deleted by their id, and one fresh token is minted", async () => {
const { env, file, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
for (const other of ["laptop", "backup", "ci"]) forge.plant(other); // the account's other tokens: kept
forge.plant("mesh-tools");
forge.plant("mesh-tools"); // gitea refuses to delete either by name from here on
forge.deletedBy.length = 0;
const before = forge.mints;
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.deletedBy, ["id", "id"]);
assert.equal(forge.twins.length, 0);
assert.deepEqual([...forge.tokens.keys()].sort(), ["backup", "ci", "laptop", "mesh-tools"]);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.includes('holds 2 tokens named "mesh-tools"')), logs.join("\n"));
});
test("two processes on one kept file, both without a token, mint once: the second waits for the first and reuses it", async () => {
const { env, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
const before = forge.mints;
// Two sources over one state directory, as the tools bundle and the watcher are two processes.
const first = minted(env, logs);
const second = minted(env, logs);
await Promise.all([first.listRepos(), second.listRepos()]);
assert.equal(forge.mints, before + 1);
assert.equal(forge.twins.length, 0);
assert.ok(logs.some((l) => l.includes("another process minted it")), logs.join("\n"));
});
test("a lock left by a process that died mid-mint is taken over, not waited on for ever", async () => {
const { env, file, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
await mkdir(join(env.MESH_GITEA_STATE_DIR!), { recursive: true });
await writeFile(`${file}.lock`, `2147483646 ${Date.now()} gone\n`); // a pid no process holds
const before = forge.mints;
assert.equal((await minted(env, logs).listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
await assert.rejects(stat(`${file}.lock`), /ENOENT/);
});
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
+332
View File
@@ -0,0 +1,332 @@
// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469):
// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the
// administrator's login and given write on the mesh's repositories through one team; the administrator's own
// token by that name goes; a token kept from before, the administrator's, is minted again for the account.
//
// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea
// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection
// edit needs the repository's administration, which neither a plain account nor its write team holds.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
interface Account { admin: boolean; password: string; active: boolean }
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
interface Team { id: number; name: string; units: any; members: Set<string> }
function fakeForge() {
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
const tokens: Token[] = [];
const teams: Team[] = [];
const calls: string[] = [];
// What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made
// between this one's look and its create (gitea answers the create 422).
const faults = { mint: false, deleteToken: false, teamRace: false };
let next = 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 t = "";
req.on("data", (c) => (t += c));
req.on("end", () => resolve(t ? JSON.parse(t) : null));
});
const who = (req: IncomingMessage): string | null => {
const h = req.headers.authorization ?? "";
if (h.startsWith("Basic ")) {
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
return users.get(u)?.password === p ? u : null;
}
if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null;
return null;
};
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const p = url.pathname.replace(/^\/api\/v1/, "");
const me = who(req);
calls.push(`${req.method} ${p} as ${me}`);
if (me === null) return json(res, 401, { message: "unauthorized" });
let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/);
if (m) {
const owner = decodeURIComponent(m[1]);
if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" });
if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" });
if (!users.has(owner)) return json(res, 404, { message: "user does not exist" });
if (req.method === "POST") {
if (faults.mint) return json(res, 500, { message: "the forge is not well" });
const { name, scopes } = await read(req);
if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" });
const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes };
tokens.push(t);
return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes });
}
if (req.method === "GET") {
const page = Number(url.searchParams.get("page") ?? "1");
const mine = tokens.filter((t) => t.user === owner);
return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []);
}
if (req.method === "DELETE" && m[2]) {
if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" });
const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2]));
if (i < 0) return json(res, 404, { message: "not found" });
tokens.splice(i, 1);
return json(res, 204, null);
}
}
if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) });
m = p.match(/^\/users\/([^/]+)$/);
if (m && req.method === "GET") {
const u = users.get(decodeURIComponent(m[1]));
if (!u) return json(res, 404, { message: "user does not exist" });
return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false });
}
if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" });
if (req.method === "POST" && p === "/admin/users") {
const b = await read(req);
if (users.has(b.username)) return json(res, 422, { message: "user already exists" });
if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" });
users.set(b.username, { admin: b.admin === true, password: b.password, active: true });
return json(res, 201, { login: b.username, is_admin: b.admin === true });
}
m = p.match(/^\/admin\/users\/([^/]+)$/);
if (m && req.method === "PATCH") {
const u = users.get(decodeURIComponent(m[1]));
if (!u) return json(res, 404, { message: "user does not exist" });
const b = await read(req);
if (b.admin !== undefined) u.admin = b.admin;
if (b.active !== undefined) u.active = b.active;
return json(res, 200, { login: m[1], is_admin: u.admin });
}
if (p === "/orgs/novox/teams") {
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name })));
const b = await read(req);
const t = { id: ++next, name: b.name, units: b, members: new Set<string>() };
teams.push(t);
if (faults.teamRace) return json(res, 422, { message: "team already exists" });
return json(res, 201, { id: t.id, name: t.name });
}
m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/);
if (m) {
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
const t = teams.find((x) => x.id === Number(m![1]));
if (!t) return json(res, 404, { message: "team does not exist" });
if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); }
if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); }
}
// Repository administration: a site administrator, or a team member whose team administers the repository.
m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/);
if (m && req.method === "GET") {
if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main",
enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false });
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
return json(res, 404, { message: "branch does not exist" });
}
// gitea's reqAdmin on the whole group: reading the rules is administration too.
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/);
if (m) {
const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin");
if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
return json(res, 201, { rule_name: "main" });
}
m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/);
if (m && req.method === "POST") {
const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write");
if (!writes) return json(res, 403, { message: "write on code required" });
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
}
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[];
faults: typeof faults; close(): Promise<void> }>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults,
close: () => new Promise((r) => server.close(() => r())) });
});
});
}
const forge = await fakeForge();
after(() => forge.close());
async function environment(): Promise<NodeJS.ProcessEnv & { MESH_GITEA_STATE_DIR: string }> {
const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-"));
const passwordFile = join(dir, "admin.secret");
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
return {
MESH_GITEA_URL: forge.url,
MESH_GITEA_ADMIN_USER: ADMIN,
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
MESH_GITEA_STATE_DIR: join(dir, "state"),
};
}
/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */
function fresh(env: NodeJS.ProcessEnv): GiteaClient {
return GiteaClient.fromEnv(env);
}
test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => {
const env = await environment();
await fresh(env).listRepos();
const account = forge.users.get(TOOLS_ACCOUNT);
assert.ok(account, "the module made the tools' account");
assert.equal(account!.admin, false, "the tools' account is no administrator");
const held = forge.tokens.filter((t) => t.name === "mesh-tools");
assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's");
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n");
const team = forge.teams.find((t) => t.name === "mesh-tools");
assert.ok(team, "the team is made in the mesh's organisation");
assert.ok(team!.members.has(TOOLS_ACCOUNT));
assert.equal(team!.units.permission, "write");
assert.equal(team!.units.includes_all_repositories, true);
assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it");
assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map);
assert.ok(!Object.values(team!.units.units_map).includes("admin"));
});
test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => {
const env = await environment();
const client = fresh(env);
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
await assert.rejects(
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
/403/,
);
});
test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => {
const env = await environment();
const client = fresh(env);
await assert.rejects(client.branchProtections("novox", "hq"), /403/);
const rule = await client.appliedProtection("novox", "hq", "main");
assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
assert.equal(rule?.enable_status_check, true);
assert.equal(rule?.rule_name, "main");
assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null);
assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null);
});
test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => {
const env = await environment();
// What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it.
const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] };
forge.tokens.push(old);
await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true });
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 });
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 });
await fresh(env).listRepos();
assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more");
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
assert.notEqual(kept, old.sha1);
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT);
assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/);
});
test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => {
const env = await environment();
forge.users.get(TOOLS_ACCOUNT)!.admin = true;
await fresh(env).listRepos();
assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false);
});
test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => {
const env = await environment();
assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/);
});
test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => {
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
const authors = String(manifest.config["ready-authors"].default).split(/\s+/);
assert.ok(authors.includes(TOOLS_ACCOUNT));
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
// their own keeps counting as ready after the delivery.
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
// The Go forge bundle reads the token the tools' account holds, never the administrator's password.
const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge");
assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE);
assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined);
});
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
const client = fresh(await environment());
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
// A base nothing guards: not required, so the head's repository check passes rather than fails.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
});
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
const env = await environment();
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
forge.tokens.push(old);
forge.faults.mint = true;
try {
await assert.rejects(fresh(env).listRepos(), /500/);
} finally {
forge.faults.mint = false;
}
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept");
forge.tokens.splice(forge.tokens.indexOf(old), 1);
});
test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => {
const env = await environment();
const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] };
forge.tokens.push(old);
forge.faults.deleteToken = true;
try {
await fresh(env).listRepos();
} finally {
forge.faults.deleteToken = false;
}
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works");
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1));
// The next process reuses the kept token and retires the old one it could not before.
const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} });
assert.equal(await next.current(), kept);
assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start");
});
test("a team another process made between the look and the create is found, not a failure", async () => {
forge.teams.splice(0);
forge.faults.teamRace = true;
try {
await fresh(await environment()).listRepos();
} finally {
forge.faults.teamRace = false;
}
const team = forge.teams.find((t) => t.name === "mesh-tools");
assert.ok(team?.members.has(TOOLS_ACCOUNT));
});
+26
View File
@@ -173,3 +173,29 @@ test("a verdict that landed out of date keeps its account on the pull request",
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
});
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
// branch's move puts its open verdicts out of date, and leaves those into main alone.
const release = "feat/57-config-and-provisioning";
const heads: Record<string, string> = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" };
const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }];
const { forge, statuses } = aForge(pulls, heads);
const gates = new GateWriter(forge);
const trunks: Record<string, string> = {};
await weighTrunks(forge, gates, trunks, "novox", "mesh-controller");
heads[release] = MOVED;
assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true);
const s = statuses.get("3".repeat(40))!;
assert.equal(s["mesh/merge-gate"].state, "pending");
assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/);
assert.equal(s["mesh/repo-check"].state, "pending");
assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left");
assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED);
// The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current.
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
});
+335
View File
@@ -0,0 +1,335 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createHmac } from "node:crypto";
import type { AddressInfo } from "node:net";
// **The forge says every event on the bus** (novox/hq issue 729): a signed delivery is published once and acted on,
// an unsigned or unverified one is refused, and an event and the poll seeing the same head announce it once.
const secret = "s".repeat(64);
const sign = (body: string, key = secret) => createHmac("sha256", key).update(body).digest("hex");
const opened = JSON.stringify({ action: "opened", number: 7, pull_request: { number: 7, title: "t", user: { login: "jo" },
head: { ref: "feat/x", sha: "a".repeat(40) }, base: { ref: "main" }, merged: false },
repository: { name: "hq", full_name: "novox/hq", owner: { login: "novox" } }, sender: { login: "jo" } });
async function serve(published: { name: string }[], acted: { name: string }[]) {
const { receiver, HOOK_PATH } = await import("../webhook.ts");
const server = receiver({ secret, port: 0, log: () => {},
publish: async (e) => { published.push(e); }, act: async (e) => { acted.push(e); } });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
return { server, url };
}
test("a signed event is published on the bus and acted on once", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const headers = { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-1" };
const res = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(res.status, 200);
// The same delivery sent again is answered and published nowhere.
const again = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(again.status, 200);
await server.idle();
assert.deepEqual(published.map((e) => e.name), ["forge.pull-request.opened"]);
assert.deepEqual(acted.map((e) => e.name), ["forge.pull-request.opened"]);
} finally { server.close(); }
});
test("an unsigned event, or one whose signature does not verify, is refused and published nowhere", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const unsigned = await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request" } });
assert.equal(unsigned.status, 401);
assert.match(await unsigned.text(), /unsigned/);
const forged = await fetch(url, { method: "POST", body: opened,
headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "another secret") } });
assert.equal(forged.status, 401);
assert.match(await forged.text(), /does not verify/);
const big = "x".repeat(5 * 1024 * 1024 + 1);
const large = await fetch(url, { method: "POST", body: big,
headers: { "X-Gitea-Event": "push", "X-Gitea-Signature": sign(big) } });
assert.equal(large.status, 413);
await server.idle();
assert.deepEqual(published, []);
assert.deepEqual(acted, []);
} finally { server.close(); }
});
test("an event and the poll seeing the same head announce it once", async () => {
const { claimHead } = await import("../webhook.ts");
const heads: Record<string, string> = {};
const sha = "a".repeat(40);
assert.equal(claimHead(heads, "novox/hq#7", sha), true, "the event claims the head and announces it");
assert.equal(claimHead(heads, "novox/hq#7", sha), false, "the poll finds it taken");
assert.equal(claimHead(heads, "novox/hq#7", "b".repeat(40)), true, "a new head is announced again");
});
test("each forge event is named in the module's stream and trimmed", async () => {
const { eventOf } = await import("../webhook.ts");
const merged = eventOf("pull_request", { action: "closed", pull_request: { number: 3, merged: true, merge_commit_sha: "c".repeat(40),
head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } });
assert.equal(merged.name, "forge.pull-request.merged");
assert.equal(merged.body.merge_commit_sha, "c".repeat(40));
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name,
"forge.pull-request.labeled");
const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 },
comment: { id: 9, body: "ready at abc", user: { login: "jo" } } });
assert.equal(comment.name, "forge.pull-request.commented");
assert.equal(comment.body.comment, "ready at abc");
assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push");
assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other");
});
// A forge's admin hooks as 1.27.3 keeps them: POST /admin/hooks makes a system webhook only when config.is_system_webhook
// is "true", and a default webhook otherwise; the listing names one kind at a time (type=system, the default, or default).
// Repositories hold their own webhooks (a default webhook's copies), listed a page at a time.
function fakeForge(start: { id: number; url: string; system: boolean }[] = [],
repoHooks: Record<string, { id: number; url: string }[]> = {}, repoCount = 0) {
const hooks = start.map((h) => ({ ...h, events: ["push"], active: true }));
const repos = [...Object.keys(repoHooks)];
for (let i = 0; repos.length < repoCount; i++) repos.push(`novox/r${i}`);
let next = 100;
const calls: string[] = [];
const made: any[] = [];
const page = (items: any[], q: URLSearchParams) => {
const p = Number(q.get("page") ?? 1), l = Number(q.get("limit") ?? 50);
return items.slice((p - 1) * l, p * l);
};
const admin = { call: async (method: string, path: string, body?: any) => {
calls.push(`${method} ${path}`);
const u = new URL(path, "http://f");
const repoHook = u.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/hooks(?:\/(\d+))?$/);
if (method === "GET" && u.pathname === "/repos/search") {
return { status: 200, body: { ok: true, data: page(repos.map((full_name) => ({ full_name })), u.searchParams) } };
}
if (repoHook) {
const list = repoHooks[repoHook[1]] ?? [];
if (method === "GET") return { status: 200, body: page(list.map((h) => ({ id: h.id, config: { url: h.url } })), u.searchParams) };
const i = list.findIndex((h) => h.id === Number(repoHook[2]));
if (i < 0) return { status: 404, body: {} };
list.splice(i, 1);
return { status: 204, body: null };
}
if (method === "GET") {
const type = u.searchParams.get("type") ?? "system";
const listed = hooks.filter((h) => type === "all" || h.system === (type === "system"));
return { status: 200, body: listed.map((h) => ({ id: h.id, active: h.active, events: h.events,
config: { url: h.url, content_type: "json" } })) };
}
if (method === "POST") {
made.push(body);
hooks.push({ id: next++, url: body.config.url, system: body.config.is_system_webhook === "true", events: body.events,
active: body.active });
return { status: 201, body: {} };
}
if (method === "DELETE") {
const id = Number(path.split("/").pop());
const i = hooks.findIndex((h) => h.id === id);
if (i < 0) return { status: 404, body: {} };
hooks.splice(i, 1);
return { status: 204, body: null };
}
return { status: 405, body: {} };
} };
return { admin, hooks, calls, made, repoHooks };
}
test("the webhook is made a system webhook, which the forge sends every repository's events to", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const forge = fakeForge();
const url = "http://h:3047/forge-event";
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.equal(forge.made.length, 1);
assert.equal(forge.made[0].config.is_system_webhook, "true", "without it the forge makes a default webhook, which fires for no repository");
assert.equal(forge.made[0].config.secret, secret);
assert.ok(forge.made[0].events.includes("pull_request") && forge.made[0].events.includes("push"));
assert.deepEqual(forge.hooks.map((h) => h.system), [true]);
assert.equal(hook.kind, "system");
assert.equal(hook.did, "created");
assert.equal(hook.url, url);
assert.ok(hook.events.includes("pull_request"));
});
test("default webhooks left at the receiver by an earlier start are replaced by one system webhook", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const url = "http://h:3047/forge-event";
// What the start at 23:05 left: a default webhook, invisible to the system listing, made again by every start.
const forge = fakeForge([{ id: 4, url, system: false }, { id: 5, url, system: false }, { id: 6, url: "http://other/x", system: true }]);
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(hook.removed, { system: 0, default: 2, repository: 0, repositories: [] });
assert.equal(hook.did, "replaced");
assert.deepEqual(forge.hooks.map((h) => [h.url, h.system]), [["http://other/x", true], [url, true]], "another url is kept");
// A second start replaces the system webhook, so the secret kept now is the one the forge signs with (its edit never
// changes a secret).
const again = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(again.removed, { system: 1, default: 0, repository: 0, repositories: [] });
assert.equal(forge.hooks.filter((h) => h.url === url).length, 1);
assert.ok(forge.calls.includes("DELETE /admin/hooks/4") && forge.calls.includes("DELETE /admin/hooks/5"));
// The new one is made before any old one is deleted, so no event falls in a gap.
const post = forge.calls.indexOf("POST /admin/hooks");
assert.ok(post >= 0 && forge.calls.findIndex((c) => c.startsWith("DELETE")) > post);
});
test("a repository's copy of a default webhook at the receiver is deleted, on every page, and said", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const url = "http://h:3047/forge-event";
// A repository made after the default webhook got its copy, which would send its events a second time; 120
// repositories, so the copies sit beyond the first page.
const repoHooks: Record<string, { id: number; url: string }[]> = {};
for (let i = 0; i < 118; i++) repoHooks[`novox/r${i}`] = [];
repoHooks["novox/late"] = [{ id: 31, url }, { id: 32, url: "http://ci/hook" }];
repoHooks["novox/later"] = [{ id: 33, url }];
const forge = fakeForge([{ id: 4, url, system: false }], repoHooks);
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(hook.removed, { system: 0, default: 1, repository: 2, repositories: ["novox/late", "novox/later"] });
assert.deepEqual(forge.repoHooks["novox/late"].map((h) => h.id), [32], "a repository's other webhook is kept");
assert.deepEqual(forge.repoHooks["novox/later"], []);
assert.ok(forge.calls.some((c) => /\/repos\/search\?.*page=3/.test(c)), "every page of repositories is read");
});
test("a forge that makes no system webhook is said, never taken as heard", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const forge = fakeForge();
const admin = { call: async (method: string, path: string, body?: any) => {
// A forge that ignores the flag: what 23:05 met, had the module read back.
if (method === "POST") body = { ...body, config: { ...body.config, is_system_webhook: undefined } };
return forge.admin.call(method, path, body);
} };
await assert.rejects(ensureSystemHook(admin, "http://h:3047/forge-event", secret), /names 0 new system webhooks/);
});
test("the receiver tells what it verified and what it refused", async () => {
const { receiver, HOOK_PATH } = await import("../webhook.ts");
const heard: string[] = [], refused: string[] = [];
const server = receiver({ secret, port: 0, log: () => {}, publish: async () => {},
heard: (kind, delivery) => heard.push(`${kind} ${delivery}`), refused: (reason) => refused.push(reason) });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
try {
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-9" } });
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "x") } });
await server.idle();
assert.deepEqual(heard, ["pull_request d-9"]);
assert.deepEqual(refused, ["its signature does not verify"]);
} finally { server.close(); }
});
test("a forge that never delivers is said loudly once, and unhealthy, when the poll finds what no event said", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
pace.listening("172.17.0.1", 3047, hook.url, hook);
now += 5 * 60_000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.length, 0, "five minutes after the start is not yet silence");
assert.equal((pace.report() as any).healthy, true);
pace.refused("it is unsigned", "172.17.0.3");
assert.equal((pace.report() as any).healthy, false, "refused, and none verified");
now += 6 * 60_000;
pace.miss("the merge of novox/hq#7");
pace.miss("novox/hq#8 at bbbbbbbb");
assert.equal(said.filter((l) => /NOT HEARD: no delivery ever verified/.test(l)).length, 1, "said once");
let r = pace.report() as any;
assert.equal(r.healthy, false);
assert.equal(r.state, "unhealthy");
assert.match(r.why, /never delivered/);
assert.equal(r.polls.found_unsaid, 3);
assert.equal(r.refused.count, 1);
assert.equal(r.refused.last.reason, "it is unsigned");
assert.equal(r.hook.kind, "system");
assert.equal(r.receiver.port, 3047);
// The first delivery clears it.
now += 1000;
pace.heard("push", "d-1");
r = pace.report() as any;
assert.equal(r.healthy, true);
assert.equal(r.verified.count, 1);
assert.equal(r.verified.last.event, "push");
assert.equal(r.polls.every_seconds, 300);
assert.match(said.join("\n"), /heard again/);
});
test("a forge whose events stop after one delivery is said loudly once per silent spell, and refusals are unhealthy", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
pace.listening("172.17.0.1", 3047, hook.url, hook);
now += 60_000;
pace.heard("push", "d-1");
// The hook is deleted by hand: nothing more comes, and the poll finds what no event said.
now += 11 * 60_000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 0, "one find alone may be the poll beating an event");
pace.miss("the merge of novox/hq#7");
pace.miss("novox/hq#8 at bbbbbbbb");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 1, "said once");
assert.match(said.find((l) => /NOT HEARD/.test(l)) ?? "", /since the last at/);
assert.equal((pace.report() as any).healthy, false);
// A delivery ends the spell; the next spell is said again.
now += 1000;
pace.heard("push", "d-2");
assert.equal((pace.report() as any).healthy, true);
now += 11 * 60_000;
pace.miss("novox/hq#9 at cccccccc");
pace.miss("novox/hq#10 at dddddddd");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 2, "a second silent spell is said again");
// Every delivery refused after a verified one (a secret that no longer matches) is unhealthy at once.
now += 1000;
pace.heard("push", "d-3");
now += 1000;
pace.refused("its signature does not verify", "172.17.0.3");
pace.refused("its signature does not verify", "172.17.0.3");
const r = pace.report() as any;
assert.equal(r.healthy, false);
assert.equal(r.refused.since_verified, 2);
assert.match(r.why, /2 deliveries refused since the last verified one.*does not verify/);
now += 1000;
pace.heard("push", "d-4");
assert.equal((pace.report() as any).healthy, true, "a verified delivery clears it");
});
test("without a receiver by configuration the polls alone watch, healthy; a receiver that failed is unhealthy", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const quiet = new Pace(() => now, (l) => said.push(l));
quiet.unheard("MESH_GITEA_HOOK_URL unset", false);
now += 20 * 60_000;
quiet.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.length, 0, "no deliveries were expected");
assert.equal((quiet.report() as any).healthy, true);
const failed = new Pace(() => now, () => {});
failed.unheard("the forge's system webhook could not be made: 403");
const r = failed.report() as any;
assert.equal(r.healthy, false);
assert.match(r.why, /403/);
});
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
assert.equal(pace.every(), 30_000, "no delivery yet");
pace.heard();
assert.equal(pace.every(), 300_000, "a delivery heard");
now += 11 * 60_000;
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
pace.heard();
now += 1000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(pace.every(), 30_000, "the poll found what no event said");
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
now += 1000;
pace.heard();
assert.equal(pace.every(), 300_000, "the next delivery");
});