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"); }); test("the system webhook is made when none points at the receiver, and corrected when one does", async () => { const { ensureSystemHook } = await import("../webhook.ts"); const calls: string[] = []; let hooks: unknown[] = []; const admin = { call: async (method: string, path: string, body?: any) => { calls.push(`${method} ${path}`); if (method === "GET") return { status: 200, body: hooks }; if (method === "POST") { assert.ok(body.events.includes("pull_request") && body.events.includes("push")); assert.equal(body.config.secret, secret); return { status: 201, body: {} }; } return { status: 200, body: {} }; } }; assert.equal(await ensureSystemHook(admin, "http://h:3047/forge-event", secret), "created"); hooks = [{ id: 4, config: { url: "http://h:3047/forge-event" } }]; assert.equal(await ensureSystemHook(admin, "http://h:3047/forge-event", secret), "corrected"); assert.ok(calls.includes("PATCH /admin/hooks/4")); }); 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"); });