mesh/merge-gate head 2833a049aa0f merged into trunk c51357f8afd4 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 2833a049aa0f merged into trunk c51357f8afd4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
POST /admin/hooks on gitea 1.27.3 makes a default webhook unless config.is_system_webhook is "true": a default webhook fires for no existing repository, and the system listing does not name it, so every start made another and no event reached the receiver. Its edit never changes a secret or a kind, so the new system webhook is made first and read back, then every other hook at the receiver is deleted: system, default, and each repository's copy of a default one, which would send its events twice. gitea_events says where the receiver listens, the hook as ensured and what it replaced, the verified and refused deliveries and the polls' pace. When nothing verified for ten minutes, since the start or the last delivery, while the poll found two heads or merges no event said, it is logged loudly once per silent spell and answered unhealthy; so are deliveries refused since the last verified one.
336 lines
18 KiB
TypeScript
336 lines
18 KiB
TypeScript
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");
|
|
});
|