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");
|
|
});
|