Skip to content
File

Blob: tests/worker/webhooks/public/lifecycle.test.ts

typescript377 lines
1import { eq } from "drizzle-orm";
2import { describe, expect, it } from "vitest";
3 
4import * as projectDoSchema from "@/worker/db/durable/schema/project-do";
5import type { ProjectDoContext } from "@/worker/durable/project-do/types";
6import { getProjectStub, readProjectDoRows, runInProjectDo } from "../../../helpers/runtime";
7import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks";
8 
9import {
10 buildGitHubRepository,
11 createOwnedProjectContext,
12 deleteWebhook,
13 getWebhooks,
14 patchProject,
15 postPublicWebhook,
16 putWebhook,
17 rotateWebhookSecret,
18 signGitHubPayload,
19} from "../helpers";
20import { AFTER_SHA, BEFORE_SHA, buildVerifiedPushDeliveryInput } from "../public-helpers";
21 
22describe("webhook public routes", () => {
23 registerWorkerRuntimeHooks();
24 
25 describe("webhook lifecycle behavior", () => {
26 it("invalidates the old secret immediately after rotation", async () => {
27 const context = await createOwnedProjectContext({
28 user: {
29 email: "webhook-public-github-rotate@example.com",
30 slug: "webhook-public-github-rotate",
31 },
32 project: {
33 projectSlug: "github-rotate-project",
34 repoUrl: "https://github.com/example/github-rotate-project",
35 },
36 });
37 
38 await putWebhook(context.sessionId, context.project.id, "github", {
39 enabled: true,
40 secret: "github-rotate-secret-old",
41 });
42 
43 const rotated = await rotateWebhookSecret(context.sessionId, context.project.id, "github");
44 expect(rotated.status).toBe(200);
45 expect(rotated.body?.secret).toEqual(expect.any(String));
46 
47 const body = JSON.stringify({
48 ref: "refs/heads/main",
49 before: BEFORE_SHA,
50 after: AFTER_SHA,
51 head_commit: {
52 id: AFTER_SHA,
53 },
54 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
55 });
56 
57 const oldSecretResponse = await postPublicWebhook("github", context.project, body, {
58 "content-type": "application/json; charset=utf-8",
59 "x-github-event": "push",
60 "x-github-delivery": "github-rotate-old-secret",
61 "x-hub-signature-256": await signGitHubPayload("github-rotate-secret-old", body),
62 });
63 expect(oldSecretResponse.status).toBe(401);
64 
65 const newSecretResponse = await postPublicWebhook("github", context.project, body, {
66 "content-type": "application/json; charset=utf-8",
67 "x-github-event": "push",
68 "x-github-delivery": "github-rotate-new-secret",
69 "x-hub-signature-256": await signGitHubPayload(rotated.body!.secret, body),
70 });
71 expect(newSecretResponse.status).toBe(202);
72 
73 const rows = await readProjectDoRows(context.project.id);
74 expect(rows.runs).toHaveLength(1);
75 expect(rows.webhookDeliveries).toHaveLength(1);
76 expect(rows.webhookDeliveries[0]?.deliveryId).toBe("github-rotate-new-secret");
77 });
78 
79 it("preserves delivery audit and dedupe state when a webhook is deleted and recreated", async () => {
80 const context = await createOwnedProjectContext({
81 user: {
82 email: "webhook-public-github-delete-recreate@example.com",
83 slug: "webhook-public-github-delete-recreate",
84 },
85 project: {
86 projectSlug: "github-delete-recreate-project",
87 repoUrl: "https://github.com/example/github-delete-recreate-project",
88 },
89 });
90 
91 await putWebhook(context.sessionId, context.project.id, "github", {
92 enabled: true,
93 secret: "github-delete-recreate-secret-a",
94 });
95 
96 const deliveryBody = JSON.stringify({
97 ref: "refs/heads/main",
98 before: BEFORE_SHA,
99 after: AFTER_SHA,
100 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
101 });
102 
103 const firstDelivery = await postPublicWebhook("github", context.project, deliveryBody, {
104 "content-type": "application/json; charset=utf-8",
105 "x-github-event": "push",
106 "x-github-delivery": "github-delete-recreate-delivery",
107 "x-hub-signature-256": await signGitHubPayload("github-delete-recreate-secret-a", deliveryBody),
108 });
109 expect(firstDelivery.status).toBe(202);
110 
111 const beforeDelete = await readProjectDoRows(context.project.id);
112 expect(beforeDelete.runs).toHaveLength(1);
113 expect(beforeDelete.webhooks).toHaveLength(1);
114 expect(beforeDelete.webhookDeliveries).toHaveLength(1);
115 
116 const deleted = await deleteWebhook(context.sessionId, context.project.id, "github");
117 expect(deleted.status).toBe(204);
118 
119 const afterDelete = await readProjectDoRows(context.project.id);
120 expect(afterDelete.webhooks).toEqual([]);
121 expect(afterDelete.webhookDeliveries).toHaveLength(1);
122 expect(afterDelete.webhookDeliveries[0]?.deliveryId).toBe("github-delete-recreate-delivery");
123 expect(afterDelete.webhookDeliveries[0]?.outcome).toBe("accepted");
124 
125 await putWebhook(context.sessionId, context.project.id, "github", {
126 enabled: true,
127 secret: "github-delete-recreate-secret-b",
128 });
129 
130 const secondDelivery = await postPublicWebhook("github", context.project, deliveryBody, {
131 "content-type": "application/json; charset=utf-8",
132 "x-github-event": "push",
133 "x-github-delivery": "github-delete-recreate-delivery",
134 "x-hub-signature-256": await signGitHubPayload("github-delete-recreate-secret-b", deliveryBody),
135 });
136 expect(secondDelivery.status).toBe(200);
137 
138 const rows = await readProjectDoRows(context.project.id);
139 expect(rows.runs).toHaveLength(1);
140 expect(rows.runs.filter((row) => row.deliveryId === "github-delete-recreate-delivery")).toHaveLength(1);
141 expect(rows.webhooks).toHaveLength(1);
142 expect(rows.webhookDeliveries).toHaveLength(1);
143 expect(rows.webhookDeliveries[0]?.deliveryId).toBe("github-delete-recreate-delivery");
144 expect(rows.webhookDeliveries[0]?.outcome).toBe("accepted");
145 
146 const listed = await getWebhooks(context.sessionId, context.project.id);
147 expect(listed.status).toBe(200);
148 expect(listed.body?.webhooks).toHaveLength(1);
149 expect(listed.body?.webhooks[0]?.recentDeliveries).toHaveLength(1);
150 expect(listed.body?.webhooks[0]?.recentDeliveries[0]?.deliveryId).toBe("github-delete-recreate-delivery");
151 });
152 
153 it("rejects stale verified deliveries after rotate, disable, or delete", async () => {
154 const rotatedContext = await createOwnedProjectContext({
155 user: {
156 email: "webhook-public-stale-rotate@example.com",
157 slug: "webhook-public-stale-rotate",
158 },
159 project: {
160 projectSlug: "github-stale-rotate-project",
161 repoUrl: "https://github.com/example/github-stale-rotate-project",
162 },
163 });
164 
165 await putWebhook(rotatedContext.sessionId, rotatedContext.project.id, "github", {
166 enabled: true,
167 secret: "github-stale-rotate-secret",
168 });
169 const rotatedStub = getProjectStub(rotatedContext.project.id);
170 const rotatedMaterial = await rotatedStub.getWebhookVerificationMaterial(rotatedContext.project.id, "github");
171 expect(rotatedMaterial).not.toBeNull();
172 await rotateWebhookSecret(rotatedContext.sessionId, rotatedContext.project.id, "github");
173 
174 const rotatedResult = await runInProjectDo(
175 rotatedContext.project.id,
176 async (instance) =>
177 await instance.recordVerifiedWebhookDelivery(
178 buildVerifiedPushDeliveryInput({
179 projectId: rotatedContext.project.id,
180 repoUrl: rotatedContext.project.repoUrl,
181 deliveryId: "github-stale-after-rotate",
182 branch: rotatedContext.project.defaultBranch,
183 verifiedWebhookUpdatedAt: rotatedMaterial!.updatedAt,
184 }),
185 ),
186 );
187 expect(rotatedResult.staleVerification).toBe(true);
188 
189 const rotatedRows = await readProjectDoRows(rotatedContext.project.id);
190 expect(rotatedRows.runs).toEqual([]);
191 expect(rotatedRows.webhookDeliveries).toEqual([]);
192 
193 const disabledContext = await createOwnedProjectContext({
194 user: {
195 email: "webhook-public-stale-disable@example.com",
196 slug: "webhook-public-stale-disable",
197 },
198 project: {
199 projectSlug: "github-stale-disable-project",
200 repoUrl: "https://github.com/example/github-stale-disable-project",
201 },
202 });
203 
204 await putWebhook(disabledContext.sessionId, disabledContext.project.id, "github", {
205 enabled: true,
206 secret: "github-stale-disable-secret",
207 });
208 const disabledStub = getProjectStub(disabledContext.project.id);
209 const disabledMaterial = await disabledStub.getWebhookVerificationMaterial(disabledContext.project.id, "github");
210 expect(disabledMaterial).not.toBeNull();
211 await putWebhook(disabledContext.sessionId, disabledContext.project.id, "github", {
212 enabled: false,
213 });
214 
215 const disabledResult = await runInProjectDo(
216 disabledContext.project.id,
217 async (instance) =>
218 await instance.recordVerifiedWebhookDelivery(
219 buildVerifiedPushDeliveryInput({
220 projectId: disabledContext.project.id,
221 repoUrl: disabledContext.project.repoUrl,
222 deliveryId: "github-stale-after-disable",
223 branch: disabledContext.project.defaultBranch,
224 verifiedWebhookUpdatedAt: disabledMaterial!.updatedAt,
225 }),
226 ),
227 );
228 expect(disabledResult.staleVerification).toBe(true);
229 
230 const disabledRows = await readProjectDoRows(disabledContext.project.id);
231 expect(disabledRows.runs).toEqual([]);
232 expect(disabledRows.webhookDeliveries).toEqual([]);
233 
234 const deletedContext = await createOwnedProjectContext({
235 user: {
236 email: "webhook-public-stale-delete@example.com",
237 slug: "webhook-public-stale-delete",
238 },
239 project: {
240 projectSlug: "github-stale-delete-project",
241 repoUrl: "https://github.com/example/github-stale-delete-project",
242 },
243 });
244 
245 await putWebhook(deletedContext.sessionId, deletedContext.project.id, "github", {
246 enabled: true,
247 secret: "github-stale-delete-secret",
248 });
249 const deletedStub = getProjectStub(deletedContext.project.id);
250 const deletedMaterial = await deletedStub.getWebhookVerificationMaterial(deletedContext.project.id, "github");
251 expect(deletedMaterial).not.toBeNull();
252 await deleteWebhook(deletedContext.sessionId, deletedContext.project.id, "github");
253 
254 const deletedResult = await runInProjectDo(
255 deletedContext.project.id,
256 async (instance) =>
257 await instance.recordVerifiedWebhookDelivery(
258 buildVerifiedPushDeliveryInput({
259 projectId: deletedContext.project.id,
260 repoUrl: deletedContext.project.repoUrl,
261 deliveryId: "github-stale-after-delete",
262 branch: deletedContext.project.defaultBranch,
263 verifiedWebhookUpdatedAt: deletedMaterial!.updatedAt,
264 }),
265 ),
266 );
267 expect(deletedResult.staleVerification).toBe(true);
268 
269 const deletedRows = await readProjectDoRows(deletedContext.project.id);
270 expect(deletedRows.runs).toEqual([]);
271 expect(deletedRows.webhookDeliveries).toEqual([]);
272 });
273 
274 it("accepts deliveries after a non-material project update such as renaming", async () => {
275 const context = await createOwnedProjectContext({
276 user: {
277 email: "webhook-public-non-material@example.com",
278 slug: "webhook-public-non-material",
279 },
280 project: {
281 projectSlug: "non-material-project",
282 repoUrl: "https://github.com/example/non-material-project",
283 name: "Original Name",
284 },
285 });
286 
287 await putWebhook(context.sessionId, context.project.id, "github", {
288 enabled: true,
289 secret: "non-material-secret",
290 });
291 
292 const renamed = await patchProject(context.sessionId, context.project.id, {
293 name: "Renamed Project",
294 });
295 expect(renamed.status).toBe(200);
296 
297 const body = JSON.stringify({
298 ref: "refs/heads/main",
299 before: BEFORE_SHA,
300 after: AFTER_SHA,
301 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
302 });
303 
304 const delivered = await postPublicWebhook("github", context.project, body, {
305 "content-type": "application/json; charset=utf-8",
306 "x-github-event": "push",
307 "x-github-delivery": "github-after-rename",
308 "x-hub-signature-256": await signGitHubPayload("non-material-secret", body),
309 });
310 expect(delivered.status).toBe(202);
311 
312 const rows = await readProjectDoRows(context.project.id);
313 expect(rows.runs).toHaveLength(1);
314 expect(rows.webhookDeliveries).toHaveLength(1);
315 expect(rows.webhookDeliveries[0]?.outcome).toBe("accepted");
316 });
317 
318 it("prunes webhook deliveries older than 72 hours on the next write", async () => {
319 const context = await createOwnedProjectContext({
320 user: { email: "webhook-prune@example.com", slug: "webhook-prune" },
321 project: {
322 projectSlug: "prune-project",
323 repoUrl: "https://github.com/example/prune-project",
324 },
325 });
326 
327 const createResult = await putWebhook(context.sessionId, context.project.id, "github", {
328 enabled: true,
329 secret: "prune-secret",
330 });
331 expect(createResult.status).toBe(201);
332 
333 for (let i = 0; i < 3; i++) {
334 const pingBody = JSON.stringify({
335 zen: "keep it logically awesome",
336 repository: buildGitHubRepository(context.project.repoUrl),
337 });
338 const pingResult = await postPublicWebhook("github", context.project, pingBody, {
339 "content-type": "application/json; charset=utf-8",
340 "x-github-event": "ping",
341 "x-github-delivery": `prune-ping-${i}`,
342 "x-hub-signature-256": await signGitHubPayload("prune-secret", pingBody),
343 });
344 expect(pingResult.status).toBe(200);
345 }
346 
347 const before = await readProjectDoRows(context.project.id);
348 expect(before.webhookDeliveries).toHaveLength(3);
349 
350 const expiredTimestamp = Date.now() - 73 * 60 * 60 * 1000;
351 await runInProjectDo(context.project.id, async (instance) => {
352 const { db } = instance as unknown as Pick<ProjectDoContext, "db">;
353 for (const row of before.webhookDeliveries.slice(0, 2)) {
354 db.update(projectDoSchema.projectWebhookDeliveries)
355 .set({ receivedAt: expiredTimestamp })
356 .where(eq(projectDoSchema.projectWebhookDeliveries.id, row.id))
357 .run();
358 }
359 });
360 
361 const listed = await getWebhooks(context.sessionId, context.project.id);
362 expect(listed.status).toBe(200);
363 const midpoint = await readProjectDoRows(context.project.id);
364 expect(midpoint.webhookDeliveries).toHaveLength(3);
365 
366 const updateResult = await putWebhook(context.sessionId, context.project.id, "github", {
367 enabled: true,
368 });
369 expect(updateResult.status).toBe(200);
370 
371 const after = await readProjectDoRows(context.project.id);
372 expect(after.webhookDeliveries).toHaveLength(1);
373 expect(after.webhookDeliveries[0]?.deliveryId).toBe("prune-ping-2");
374 });
375 });
376});