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((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 = {}; 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 = {}, 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 = {}; 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((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"); });