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.

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