diff --git a/worker/src/github-ip.ts b/worker/src/github-ip.ts index 8d8bab7..b112e49 100644 --- a/worker/src/github-ip.ts +++ b/worker/src/github-ip.ts @@ -49,8 +49,14 @@ function parseIPv4(ip: string): number | null { return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0; } -/** Check if an IPv4 address matches any of the given CIDRs. */ -function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean { +/** + * Check if an IPv4 address matches any of the given CIDRs. + * + * Exported for unit testing (#223). IPv6 CIDRs in the list are skipped, and an + * IPv6 / unparseable client IP passes through as `true` (GitHub rarely sends + * webhooks over IPv6, so the allowlist does not block them). + */ +export function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean { const ipInt = parseIPv4(ip); if (ipInt === null) { // IPv6 — check string prefix match against IPv6 CIDRs diff --git a/worker/test/github-ip.test.ts b/worker/test/github-ip.test.ts new file mode 100644 index 0000000..8da995c --- /dev/null +++ b/worker/test/github-ip.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for worker/src/github-ip.ts (#223). + * + * Two surfaces: + * - ipMatchesCIDRs: pure IPv4 CIDR membership math (boundaries, /32, /0, + * IPv6-CIDR skip, IPv6 / unparseable client passthrough). + * - isGitHubWebhookIP: the request-level guard. The GitHub-meta fetch is + * stubbed to fail so the function falls back to the hardcoded FALLBACK_CIDRS + * deterministically (no real network in CI). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { ipMatchesCIDRs, isGitHubWebhookIP } from "../src/github-ip.js"; + +// A GitHub webhook range from the hardcoded fallback list. +const GH_RANGE = "140.82.112.0/20"; // covers 140.82.112.0 – 140.82.127.255 + +// ── ipMatchesCIDRs: membership math ────────────────────────────────── + +test("matches an address inside a /20 range", () => { + assert.equal(ipMatchesCIDRs("140.82.112.5", [GH_RANGE]), true); + assert.equal(ipMatchesCIDRs("140.82.120.200", [GH_RANGE]), true); +}); + +test("respects /20 range boundaries", () => { + assert.equal(ipMatchesCIDRs("140.82.127.255", [GH_RANGE]), true, "last addr in range"); + assert.equal(ipMatchesCIDRs("140.82.128.0", [GH_RANGE]), false, "first addr past range"); + assert.equal(ipMatchesCIDRs("140.82.111.255", [GH_RANGE]), false, "addr just below range"); +}); + +test("rejects an address outside every CIDR", () => { + assert.equal(ipMatchesCIDRs("8.8.8.8", [GH_RANGE, "185.199.108.0/22"]), false); +}); + +test("/32 matches only the exact host", () => { + assert.equal(ipMatchesCIDRs("1.2.3.4", ["1.2.3.4/32"]), true); + assert.equal(ipMatchesCIDRs("1.2.3.5", ["1.2.3.4/32"]), false); +}); + +test("/0 matches any IPv4 address", () => { + assert.equal(ipMatchesCIDRs("203.0.113.7", ["0.0.0.0/0"]), true); +}); + +test("IPv6 CIDRs in the list are skipped (no match for an IPv4 client)", () => { + assert.equal(ipMatchesCIDRs("8.8.8.8", ["2606:50c0::/32"]), false); +}); + +test("IPv6 client address passes through as true", () => { + assert.equal(ipMatchesCIDRs("2606:50c0::1", [GH_RANGE]), true); +}); + +test("unparseable IPv4 (octet > 255) passes through as true", () => { + // parseIPv4 returns null → treated as the IPv6/passthrough branch. + assert.equal(ipMatchesCIDRs("999.1.1.1", [GH_RANGE]), true); +}); + +// ── isGitHubWebhookIP: request guard ───────────────────────────────── + +test("isGitHubWebhookIP allows requests without CF-Connecting-IP (local dev)", async () => { + const req = new Request("https://worker/webhooks/github", { method: "POST" }); + assert.equal(await isGitHubWebhookIP(req), true); +}); + +test("isGitHubWebhookIP allows a GitHub-range IP via the fallback list", async () => { + const realFetch = globalThis.fetch; + // Force the meta fetch to fail so getGitHubHookCIDRs uses FALLBACK_CIDRS. + globalThis.fetch = (async () => { throw new Error("network disabled in test"); }) as typeof fetch; + try { + const req = new Request("https://worker/webhooks/github", { + method: "POST", + headers: { "CF-Connecting-IP": "140.82.112.50" }, + }); + assert.equal(await isGitHubWebhookIP(req), true); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("isGitHubWebhookIP blocks a non-GitHub IP via the fallback list", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { throw new Error("network disabled in test"); }) as typeof fetch; + try { + const req = new Request("https://worker/webhooks/github", { + method: "POST", + headers: { "CF-Connecting-IP": "8.8.8.8" }, + }); + assert.equal(await isGitHubWebhookIP(req), false); + } finally { + globalThis.fetch = realFetch; + } +}); diff --git a/worker/test/rate-limit.test.ts b/worker/test/rate-limit.test.ts new file mode 100644 index 0000000..ef69bf8 --- /dev/null +++ b/worker/test/rate-limit.test.ts @@ -0,0 +1,118 @@ +/** + * Unit tests for worker/src/rate-limit.ts (#223). + * + * Covers the in-memory sliding-window limiter (webhook 300/min, api 120/min), + * window reset after expiry, the DO-backed checkTenantQuota branch logic, and + * the 429 helper. The limiter keeps module-level per-key state, so each test + * uses a unique IP to stay independent of the others. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + checkWebhookRateLimit, + checkApiRateLimit, + checkTenantQuota, + rateLimitResponse, +} from "../src/rate-limit.js"; + +// ── Sliding window limits ──────────────────────────────────────────── + +test("webhook limiter allows exactly 300 requests then blocks", () => { + const ip = "10.0.0.1"; + for (let i = 1; i <= 300; i++) { + assert.equal(checkWebhookRateLimit(ip), true, `request ${i} should pass`); + } + assert.equal(checkWebhookRateLimit(ip), false, "301st request must be blocked"); +}); + +test("api limiter allows exactly 120 requests then blocks", () => { + const ip = "10.0.0.2"; + for (let i = 1; i <= 120; i++) { + assert.equal(checkApiRateLimit(ip), true, `request ${i} should pass`); + } + assert.equal(checkApiRateLimit(ip), false, "121st request must be blocked"); +}); + +test("webhook and api counters are independent for the same IP", () => { + const ip = "10.0.0.3"; + // Exhaust webhook budget. + for (let i = 0; i < 300; i++) checkWebhookRateLimit(ip); + assert.equal(checkWebhookRateLimit(ip), false, "webhook exhausted"); + // API budget for the same IP is untouched. + assert.equal(checkApiRateLimit(ip), true, "api budget independent"); +}); + +test("counter resets after the window expires", () => { + const ip = "10.0.0.4"; + const realNow = Date.now; + let t = realNow(); + Date.now = () => t; + try { + for (let i = 0; i < 120; i++) checkApiRateLimit(ip); + assert.equal(checkApiRateLimit(ip), false, "blocked within the window"); + // Advance past the 60s window. + t += 60_001; + assert.equal(checkApiRateLimit(ip), true, "allowed again after window reset"); + } finally { + Date.now = realNow; + } +}); + +// ── checkTenantQuota: DO-backed branch logic ───────────────────────── + +/** Minimal DurableObjectStub mock that returns a fixed response and records the request. */ +function stubRegistry(response: Response, captured?: { req?: Request }) { + return { + fetch: async (input: Request | string | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + if (captured) captured.req = req; + return response; + }, + } as unknown as DurableObjectStub; +} + +test("checkTenantQuota allows when the registry returns 200", async () => { + const captured: { req?: Request } = {}; + const registry = stubRegistry( + Response.json({ allowed: true, events_stored: 1, events_limit: 10000 }), + captured, + ); + const result = await checkTenantQuota(registry, 42); + assert.deepEqual(result, { allowed: true }); + // It posts to the /quota-check endpoint with the account_id. + assert.equal(captured.req!.method, "POST"); + assert.match(captured.req!.url, /\/quota-check$/); + assert.deepEqual(await captured.req!.json(), { account_id: 42 }); +}); + +test("checkTenantQuota blocks with a 429 response when the registry returns 429", async () => { + const registry = stubRegistry( + Response.json({ allowed: false, reason: "quota exceeded" }, { status: 429 }), + ); + const result = await checkTenantQuota(registry, 7); + assert.equal(result.allowed, false); + if (result.allowed === false) { + assert.equal(result.response.status, 429); + assert.equal(result.response.headers.get("Retry-After"), "3600"); + assert.deepEqual(await result.response.json(), { error: "tenant quota exceeded" }); + } +}); + +test("checkTenantQuota passes through (allowed) on 404 unknown tenant", async () => { + const registry = stubRegistry( + Response.json({ allowed: false, reason: "tenant not found" }, { status: 404 }), + ); + const result = await checkTenantQuota(registry, 999); + // 404 is left to downstream tenant resolution; the quota gate does not block. + assert.deepEqual(result, { allowed: true }); +}); + +// ── 429 helper ─────────────────────────────────────────────────────── + +test("rateLimitResponse returns 429 with Retry-After: 60", async () => { + const res = rateLimitResponse(); + assert.equal(res.status, 429); + assert.equal(res.headers.get("Retry-After"), "60"); + assert.equal(await res.text(), "Too Many Requests"); +}); diff --git a/worker/test/summarize.test.ts b/worker/test/summarize.test.ts new file mode 100644 index 0000000..3f4042b --- /dev/null +++ b/worker/test/summarize.test.ts @@ -0,0 +1,204 @@ +/** + * Unit tests for shared/src/summarize.ts :: summarizeEvent (#223). + * + * summarizeEvent is the core "webhook payload → lightweight EventSummary" + * projection, shared between the Worker (store.ts broadcast + list_pending_events) + * and the mcp-server / local-mcp bridge. It had zero direct coverage before this. + * + * These tests pin the field-extraction fallback ladders (number / title / url), + * the workflow_run-only fields, and null safety on sparse payloads. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { summarizeEvent } from "../../shared/src/summarize.js"; +import type { WebhookEvent } from "../../shared/src/types.js"; + +function baseEvent(overrides: Partial = {}): WebhookEvent { + return { + id: "delivery-1", + type: "issues", + received_at: "2026-05-01T00:00:00.000Z", + processed: false, + payload: {}, + ...overrides, + }; +} + +// ── Passthrough fields ─────────────────────────────────────────────── + +test("passes through id / type / received_at / processed verbatim", () => { + const s = summarizeEvent(baseEvent({ + id: "abc", + type: "push", + received_at: "2026-05-02T12:00:00.000Z", + processed: true, + })); + assert.equal(s.id, "abc"); + assert.equal(s.type, "push"); + assert.equal(s.received_at, "2026-05-02T12:00:00.000Z"); + assert.equal(s.processed, true); +}); + +test("trigger_status / last_triggered_at default to null when omitted", () => { + const s = summarizeEvent(baseEvent()); + assert.equal(s.trigger_status, null); + assert.equal(s.last_triggered_at, null); +}); + +test("trigger_status / last_triggered_at pass through when present", () => { + const s = summarizeEvent(baseEvent({ + trigger_status: "triggered", + last_triggered_at: "2026-05-03T01:02:03.000Z", + })); + assert.equal(s.trigger_status, "triggered"); + assert.equal(s.last_triggered_at, "2026-05-03T01:02:03.000Z"); +}); + +// ── Empty payload → all derived fields null ────────────────────────── + +test("empty payload yields all-null derived fields", () => { + const s = summarizeEvent(baseEvent({ payload: {} })); + assert.equal(s.action, null); + assert.equal(s.repo, null); + assert.equal(s.sender, null); + assert.equal(s.number, null); + assert.equal(s.title, null); + assert.equal(s.url, null); + assert.equal(s.head_branch, null); + assert.equal(s.head_sha, null); + assert.equal(s.conclusion, null); +}); + +// ── action / repo / sender ─────────────────────────────────────────── + +test("extracts action, repo full_name, and sender login", () => { + const s = summarizeEvent(baseEvent({ + payload: { + action: "opened", + repository: { full_name: "octo/repo" }, + sender: { login: "alice" }, + }, + })); + assert.equal(s.action, "opened"); + assert.equal(s.repo, "octo/repo"); + assert.equal(s.sender, "alice"); +}); + +// ── number ladder: top-level > issue > pull_request ────────────────── + +test("issue payload supplies number / title / url", () => { + const s = summarizeEvent(baseEvent({ + type: "issues", + payload: { + issue: { number: 5, title: "A bug", html_url: "https://gh/issues/5" }, + }, + })); + assert.equal(s.number, 5); + assert.equal(s.title, "A bug"); + assert.equal(s.url, "https://gh/issues/5"); +}); + +test("pull_request payload supplies number / title / url", () => { + const s = summarizeEvent(baseEvent({ + type: "pull_request", + payload: { + pull_request: { number: 9, title: "A PR", html_url: "https://gh/pull/9" }, + }, + })); + assert.equal(s.number, 9); + assert.equal(s.title, "A PR"); + assert.equal(s.url, "https://gh/pull/9"); +}); + +test("top-level number wins over issue.number", () => { + const s = summarizeEvent(baseEvent({ + payload: { number: 1, issue: { number: 2 } }, + })); + assert.equal(s.number, 1); +}); + +test("issue.number wins over pull_request.number", () => { + const s = summarizeEvent(baseEvent({ + payload: { + issue: { number: 2 }, + pull_request: { number: 3 }, + }, + })); + assert.equal(s.number, 2); +}); + +// ── title ladder: issue > pull_request > discussion > check_run > workflow_run > workflow_job ── + +test("issue.title wins over pull_request.title", () => { + const s = summarizeEvent(baseEvent({ + payload: { + issue: { title: "issue title" }, + pull_request: { title: "pr title" }, + }, + })); + assert.equal(s.title, "issue title"); +}); + +test("discussion supplies title and url", () => { + const s = summarizeEvent(baseEvent({ + type: "discussion", + payload: { discussion: { title: "How do I?", html_url: "https://gh/d/1" } }, + })); + assert.equal(s.title, "How do I?"); + assert.equal(s.url, "https://gh/d/1"); + // eventNumber does not read discussion.number → stays null. + assert.equal(s.number, null); +}); + +test("check_run supplies title from name and url", () => { + const s = summarizeEvent(baseEvent({ + type: "check_run", + payload: { check_run: { name: "build", html_url: "https://gh/c/1" } }, + })); + assert.equal(s.title, "build"); + assert.equal(s.url, "https://gh/c/1"); +}); + +test("workflow_job supplies title from name (last title fallback)", () => { + const s = summarizeEvent(baseEvent({ + type: "workflow_job", + payload: { workflow_job: { name: "lint" } }, + })); + assert.equal(s.title, "lint"); +}); + +// ── workflow_run-only fields ───────────────────────────────────────── + +test("workflow_run event exposes head_branch / head_sha / conclusion + title/url", () => { + const s = summarizeEvent(baseEvent({ + type: "workflow_run", + payload: { + workflow_run: { + name: "CI", + html_url: "https://gh/runs/1", + head_branch: "main", + head_sha: "deadbeef", + conclusion: "success", + }, + }, + })); + assert.equal(s.title, "CI"); + assert.equal(s.url, "https://gh/runs/1"); + assert.equal(s.head_branch, "main"); + assert.equal(s.head_sha, "deadbeef"); + assert.equal(s.conclusion, "success"); +}); + +test("workflow_run fields stay null when event type is not workflow_run", () => { + // Even if a workflow_run object is present, the type gate suppresses the fields. + const s = summarizeEvent(baseEvent({ + type: "issues", + payload: { + workflow_run: { head_branch: "main", head_sha: "x", conclusion: "success" }, + }, + })); + assert.equal(s.head_branch, null); + assert.equal(s.head_sha, null); + assert.equal(s.conclusion, null); +});