| 1 | import { DatabaseSync } from "node:sqlite"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import type { Env } from "../../src/env.js"; |
| 4 | import { Collab } from "../../src/repo/collab.js"; |
| 5 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| 6 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 7 | import { deliverWebhook, WEBHOOK_RETRY_DELAYS, type WebhookDeps, type WebhookRetry } from "../../src/lib/webhooks.js"; |
| 8 | |
| 9 | const hook = { id: "h1", url: "https://hooks.example/x", secret: "s3cret" }; |
| 10 | const msg: WebhookRetry = { type: "webhook_retry", repoId: "r1", hookId: "h1", event: "push", diffineryEvent: "push", body: '{"ok":true}', delivery: "d1", attempt: 0 }; |
| 11 | |
| 12 | function harness(statuses: Array<number | Error>, current: typeof hook | null = hook) { |
| 13 | const recorded: number[] = []; |
| 14 | const requeued: Array<{ r: WebhookRetry; delay: number }> = []; |
| 15 | const sent: Request[] = []; |
| 16 | const deps: WebhookDeps = { |
| 17 | hook: async () => current, |
| 18 | record: async (_repo, _hook, status) => void recorded.push(status), |
| 19 | requeue: async (r, delay) => void requeued.push({ r, delay }), |
| 20 | fetch: (async (url: string, init: RequestInit) => { |
| 21 | sent.push(new Request(url, init)); |
| 22 | const next = statuses.shift()!; |
| 23 | if (next instanceof Error) throw next; |
| 24 | return new Response(null, { status: next }); |
| 25 | }) as typeof fetch, |
| 26 | }; |
| 27 | return { deps, recorded, requeued, sent }; |
| 28 | } |
| 29 | |
| 30 | describe("webhook delivery", () => { |
| 31 | it("signs the body and does not retry a success", async () => { |
| 32 | const h = harness([200]); |
| 33 | expect(await deliverWebhook(h.deps, msg)).toBe(200); |
| 34 | expect(h.recorded).toEqual([200]); |
| 35 | expect(h.requeued).toEqual([]); |
| 36 | expect(h.sent[0]!.headers.get("x-github-event")).toBe("push"); |
| 37 | expect(h.sent[0]!.headers.get("x-hub-signature-256")).toMatch(/^sha256=[0-9a-f]{64}$/); |
| 38 | }); |
| 39 | |
| 40 | it("retries network errors, rate limits and server errors with growing delays", async () => { |
| 41 | for (const failure of [new Error("connection reset"), 429, 503]) { |
| 42 | const h = harness([failure]); |
| 43 | await deliverWebhook(h.deps, msg); |
| 44 | expect(h.requeued).toEqual([{ r: { ...msg, attempt: 1 }, delay: WEBHOOK_RETRY_DELAYS[0] }]); |
| 45 | } |
| 46 | const h = harness([500]); |
| 47 | await deliverWebhook(h.deps, { ...msg, attempt: 2 }); |
| 48 | expect(h.requeued[0]!.delay).toBe(WEBHOOK_RETRY_DELAYS[2]); |
| 49 | expect(h.requeued[0]!.r.delivery).toBe("d1"); |
| 50 | }); |
| 51 | |
| 52 | it("does not retry client errors, and gives up after the last delay", async () => { |
| 53 | const rejected = harness([404]); |
| 54 | await deliverWebhook(rejected.deps, msg); |
| 55 | expect(rejected.requeued).toEqual([]); |
| 56 | const last = harness([500]); |
| 57 | await deliverWebhook(last.deps, { ...msg, attempt: WEBHOOK_RETRY_DELAYS.length }); |
| 58 | expect(last.recorded).toEqual([500]); |
| 59 | expect(last.requeued).toEqual([]); |
| 60 | }); |
| 61 | |
| 62 | it("drops a retry when the hook was deleted or disabled", async () => { |
| 63 | const h = harness([200], null); |
| 64 | expect(await deliverWebhook(h.deps, { ...msg, attempt: 1 })).toBeNull(); |
| 65 | expect(h.sent).toEqual([]); |
| 66 | }); |
| 67 | |
| 68 | it("never delivers to an internal address or Diffinery itself, even for a hook saved before URLs were checked", async () => { |
| 69 | for (const url of ["https://127.0.0.1/x", "https://localhost./x", "http://hooks.example/x", "https://diffinery.com/api/v3/x"]) { |
| 70 | const h = harness([200], { ...hook, url }); |
| 71 | expect(await deliverWebhook({ ...h.deps, ownHost: "diffinery.com" }, msg)).toBe(0); |
| 72 | expect(h.sent).toEqual([]); |
| 73 | expect(h.recorded).toEqual([0]); |
| 74 | expect(h.requeued).toEqual([]); |
| 75 | } |
| 76 | }); |
| 77 | }); |
| 78 | |
| 79 | // ---------------------------------------------------------------- signing secrets at rest |
| 80 | |
| 81 | /** A minimal SqlStorage over node:sqlite with the repository schema. */ |
| 82 | function sqlStorage(): SqlStorage { |
| 83 | const db = new DatabaseSync(":memory:"); |
| 84 | db.exec(REPO_SCHEMA); |
| 85 | const sql = { |
| 86 | exec(query: string, ...bindings: unknown[]) { |
| 87 | const stmt = db.prepare(query); |
| 88 | if (/^\s*select/i.test(query)) { |
| 89 | const rows = stmt.all(...(bindings as never[])); |
| 90 | return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; |
| 91 | } |
| 92 | stmt.run(...(bindings as never[])); |
| 93 | return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; |
| 94 | }, |
| 95 | } as unknown as SqlStorage; |
| 96 | migrateRepoSchema(sql); |
| 97 | return sql; |
| 98 | } |
| 99 | |
| 100 | describe("webhook secrets", () => { |
| 101 | const env = { MASTER_KEY: Buffer.alloc(32, 7).toString("base64") } as Env; |
| 102 | const collab = () => { |
| 103 | const sql = sqlStorage(); |
| 104 | return { sql, collab: new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, env) }; |
| 105 | }; |
| 106 | const stored = (sql: SqlStorage, id: string) => sql.exec<{ secret: string; secret_encrypted: number }>("SELECT secret, secret_encrypted FROM webhooks WHERE id = ?", id).one(); |
| 107 | |
| 108 | it("are stored encrypted, never listed, and decrypted for delivery", async () => { |
| 109 | const { sql, collab: c } = collab(); |
| 110 | const made = await c.createWebhook("https://hooks.example/x", ["push"], "s3cret", "diffinery.test"); |
| 111 | expect(made.secret).toBe("s3cret"); |
| 112 | const row = stored(sql, made.id); |
| 113 | expect(row.secret_encrypted).toBe(1); |
| 114 | expect(row.secret).toMatch(/^v1\./); |
| 115 | expect(row.secret).not.toContain("s3cret"); |
| 116 | expect(c.webhooks()[0]).not.toHaveProperty("secret"); |
| 117 | expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["s3cret"]); |
| 118 | // A generated secret is shown once and encrypted the same way. |
| 119 | const generated = await c.createWebhook("https://hooks.example/y", [], undefined, "diffinery.test"); |
| 120 | expect(stored(sql, generated.id).secret).not.toContain(generated.secret); |
| 121 | expect((await c.deliverableWebhooks()).find((h) => h.id === generated.id)?.secret).toBe(generated.secret); |
| 122 | }); |
| 123 | |
| 124 | it("can't be moved to another hook", async () => { |
| 125 | const { sql, collab: c } = collab(); |
| 126 | const a = await c.createWebhook("https://hooks.example/a", [], "aaa", "diffinery.test"); |
| 127 | const b = await c.createWebhook("https://hooks.example/b", [], "bbb", "diffinery.test"); |
| 128 | sql.exec("UPDATE webhooks SET secret = ? WHERE id = ?", stored(sql, a.id).secret, b.id); |
| 129 | // Only the hook whose secret doesn't decrypt goes undelivered. |
| 130 | expect((await c.deliverableWebhooks()).map((h) => [h.id, h.secret])).toEqual([[a.id, "aaa"]]); |
| 131 | }); |
| 132 | |
| 133 | it("saved in plaintext before keep signing, and are encrypted on their next delivery", async () => { |
| 134 | const { sql, collab: c } = collab(); |
| 135 | // A row as older versions wrote it; one that looks like an envelope is still taken as plaintext. |
| 136 | sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES ('old', 'https://hooks.example/old', 'v1.not.encrypted', '[\"*\"]', '2026-01-01T00:00:00Z')"); |
| 137 | expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]); |
| 138 | const row = stored(sql, "old"); |
| 139 | expect(row.secret_encrypted).toBe(1); |
| 140 | expect(row.secret).not.toBe("v1.not.encrypted"); |
| 141 | expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]); |
| 142 | }); |
| 143 | |
| 144 | it("leave inactive hooks out of delivery", async () => { |
| 145 | const { sql, collab: c } = collab(); |
| 146 | const h = await c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test"); |
| 147 | sql.exec("UPDATE webhooks SET active = 0 WHERE id = ?", h.id); |
| 148 | expect(await c.deliverableWebhooks()).toEqual([]); |
| 149 | expect(c.webhooks()).toHaveLength(1); |
| 150 | }); |
| 151 | |
| 152 | it("can't be stored without MASTER_KEY", async () => { |
| 153 | const sql = sqlStorage(); |
| 154 | const c = new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, {} as Env); |
| 155 | await expect(c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test")).rejects.toThrow(/is MASTER_KEY configured/); |
| 156 | expect(c.webhooks()).toEqual([]); |
| 157 | }); |
| 158 | }); |