Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

158 lines · 7.7 KBRaw History
1import { DatabaseSync } from "node:sqlite";
2import { describe, expect, it } from "vitest";
3import type { Env } from "../../src/env.js";
4import { Collab } from "../../src/repo/collab.js";
5import type { RepoDO } from "../../src/repo/RepoDO.js";
6import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
7import { deliverWebhook, WEBHOOK_RETRY_DELAYS, type WebhookDeps, type WebhookRetry } from "../../src/lib/webhooks.js";
8
9const hook = { id: "h1", url: "https://hooks.example/x", secret: "s3cret" };
10const msg: WebhookRetry = { type: "webhook_retry", repoId: "r1", hookId: "h1", event: "push", diffineryEvent: "push", body: '{"ok":true}', delivery: "d1", attempt: 0 };
11
12function 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
30describe("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. */
82function 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
100describe("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});