Skip to content
File

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

typescript519 lines
1import { describe, expect, it } from "vitest";
2 
3import { readProjectDoRows } from "../../../helpers/runtime";
4import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks";
5 
6import {
7 buildGitHubRepository,
8 buildGitLabProjectPayload,
9 buildGitLabRepositoryPayload,
10 createOwnedProjectContext,
11 postPublicWebhook,
12 putWebhook,
13 signGitHubPayload,
14} from "../helpers";
15import { AFTER_SHA, BEFORE_SHA } from "../public-helpers";
16 
17describe("webhook public routes", () => {
18 registerWorkerRuntimeHooks();
19 
20 describe("error handling", () => {
21 it("returns expected public-route errors for missing or disabled webhooks, unsupported content types, and invalid JSON", async () => {
22 const missingContext = await createOwnedProjectContext({
23 user: {
24 email: "webhook-public-missing@example.com",
25 slug: "webhook-public-missing",
26 },
27 project: {
28 projectSlug: "missing-webhook-project",
29 repoUrl: "https://github.com/example/missing-webhook-project",
30 },
31 });
32 
33 const missingWebhook = await postPublicWebhook("github", missingContext.project, "{}", {
34 "content-type": "application/json; charset=utf-8",
35 });
36 expect(missingWebhook.status).toBe(404);
37 
38 const disabledContext = await createOwnedProjectContext({
39 user: {
40 email: "webhook-public-disabled@example.com",
41 slug: "webhook-public-disabled",
42 },
43 project: {
44 projectSlug: "disabled-webhook-project",
45 repoUrl: "https://github.com/example/disabled-webhook-project",
46 },
47 });
48 
49 await putWebhook(disabledContext.sessionId, disabledContext.project.id, "github", {
50 enabled: false,
51 secret: "disabled-webhook-secret",
52 });
53 
54 const validPushBody = JSON.stringify({
55 ref: "refs/heads/main",
56 before: BEFORE_SHA,
57 after: AFTER_SHA,
58 repository: buildGitHubRepository(disabledContext.project.repoUrl, disabledContext.project.defaultBranch),
59 });
60 
61 const disabledWebhook = await postPublicWebhook("github", disabledContext.project, validPushBody, {
62 "content-type": "application/json; charset=utf-8",
63 "x-github-event": "push",
64 "x-github-delivery": "disabled-webhook-delivery",
65 "x-hub-signature-256": await signGitHubPayload("disabled-webhook-secret", validPushBody),
66 });
67 expect(disabledWebhook.status).toBe(404);
68 
69 const unsupportedMediaType = await postPublicWebhook("github", disabledContext.project, validPushBody, {
70 "content-type": "text/plain",
71 "x-github-event": "push",
72 "x-github-delivery": "unsupported-media-delivery",
73 "x-hub-signature-256": await signGitHubPayload("disabled-webhook-secret", validPushBody),
74 });
75 expect(unsupportedMediaType.status).toBe(415);
76 
77 const invalidJsonContext = await createOwnedProjectContext({
78 user: {
79 email: "webhook-public-invalid-json@example.com",
80 slug: "webhook-public-invalid-json",
81 },
82 project: {
83 projectSlug: "invalid-json-project",
84 repoUrl: "https://github.com/example/invalid-json-project",
85 },
86 });
87 
88 await putWebhook(invalidJsonContext.sessionId, invalidJsonContext.project.id, "github", {
89 enabled: true,
90 secret: "invalid-json-secret",
91 });
92 
93 const invalidJsonBody = "{not-valid-json";
94 const invalidJson = await postPublicWebhook("github", invalidJsonContext.project, invalidJsonBody, {
95 "content-type": "application/json; charset=utf-8",
96 "x-github-event": "push",
97 "x-github-delivery": "invalid-json-delivery",
98 "x-hub-signature-256": await signGitHubPayload("invalid-json-secret", invalidJsonBody),
99 });
100 expect(invalidJson.status).toBe(400);
101 
102 const invalidJsonBadSignature = await postPublicWebhook("github", invalidJsonContext.project, invalidJsonBody, {
103 "content-type": "application/json; charset=utf-8",
104 "x-github-event": "push",
105 "x-github-delivery": "invalid-json-bad-signature-delivery",
106 "x-hub-signature-256": await signGitHubPayload("wrong-invalid-json-secret", invalidJsonBody),
107 });
108 expect(invalidJsonBadSignature.status).toBe(401);
109 
110 const missingRows = await readProjectDoRows(missingContext.project.id);
111 expect(missingRows.runs).toEqual([]);
112 expect(missingRows.webhookDeliveries).toEqual([]);
113 
114 const disabledRows = await readProjectDoRows(disabledContext.project.id);
115 expect(disabledRows.runs).toEqual([]);
116 expect(disabledRows.webhookDeliveries).toEqual([]);
117 
118 const invalidJsonRows = await readProjectDoRows(invalidJsonContext.project.id);
119 expect(invalidJsonRows.runs).toEqual([]);
120 expect(invalidJsonRows.webhookDeliveries).toEqual([]);
121 });
122 
123 it("rejects invalid signatures, repo mismatches, and missing headers without recording a delivery", async () => {
124 const invalidSignatureContext = await createOwnedProjectContext({
125 user: {
126 email: "webhook-public-github-invalid@example.com",
127 slug: "webhook-public-github-invalid",
128 },
129 project: {
130 projectSlug: "github-invalid-project",
131 repoUrl: "https://github.com/example/github-invalid-project",
132 },
133 });
134 
135 await putWebhook(invalidSignatureContext.sessionId, invalidSignatureContext.project.id, "github", {
136 enabled: true,
137 secret: "github-invalid-secret",
138 });
139 
140 const validBody = JSON.stringify({
141 ref: "refs/heads/main",
142 before: BEFORE_SHA,
143 after: AFTER_SHA,
144 repository: buildGitHubRepository(
145 invalidSignatureContext.project.repoUrl,
146 invalidSignatureContext.project.defaultBranch,
147 ),
148 });
149 
150 const invalidSignature = await postPublicWebhook("github", invalidSignatureContext.project, validBody, {
151 "content-type": "application/json; charset=utf-8",
152 "x-github-event": "push",
153 "x-github-delivery": "github-invalid-signature",
154 "x-hub-signature-256": await signGitHubPayload("not-the-right-secret", validBody),
155 });
156 expect(invalidSignature.status).toBe(401);
157 
158 const repoMismatchContext = await createOwnedProjectContext({
159 user: {
160 email: "webhook-public-github-mismatch@example.com",
161 slug: "webhook-public-github-mismatch",
162 },
163 project: {
164 projectSlug: "github-mismatch-project",
165 repoUrl: "https://github.com/example/github-mismatch-project",
166 },
167 });
168 
169 await putWebhook(repoMismatchContext.sessionId, repoMismatchContext.project.id, "github", {
170 enabled: true,
171 secret: "github-mismatch-secret",
172 });
173 
174 const mismatchBody = JSON.stringify({
175 ref: "refs/heads/main",
176 before: BEFORE_SHA,
177 after: AFTER_SHA,
178 repository: buildGitHubRepository(
179 "https://github.com/example/another-project",
180 repoMismatchContext.project.defaultBranch,
181 ),
182 });
183 
184 const repoMismatch = await postPublicWebhook("github", repoMismatchContext.project, mismatchBody, {
185 "content-type": "application/json; charset=utf-8",
186 "x-github-event": "push",
187 "x-github-delivery": "github-repo-mismatch",
188 "x-hub-signature-256": await signGitHubPayload("github-mismatch-secret", mismatchBody),
189 });
190 expect(repoMismatch.status).toBe(403);
191 
192 const missingHeaderContext = await createOwnedProjectContext({
193 user: {
194 email: "webhook-public-github-missing-header@example.com",
195 slug: "webhook-public-github-missing-header",
196 },
197 project: {
198 projectSlug: "github-missing-header-project",
199 repoUrl: "https://github.com/example/github-missing-header-project",
200 },
201 });
202 
203 await putWebhook(missingHeaderContext.sessionId, missingHeaderContext.project.id, "github", {
204 enabled: true,
205 secret: "github-missing-header-secret",
206 });
207 
208 const missingHeaderBody = JSON.stringify({
209 ref: "refs/heads/main",
210 before: BEFORE_SHA,
211 after: AFTER_SHA,
212 repository: buildGitHubRepository(
213 missingHeaderContext.project.repoUrl,
214 missingHeaderContext.project.defaultBranch,
215 ),
216 });
217 
218 const missingHeader = await postPublicWebhook("github", missingHeaderContext.project, missingHeaderBody, {
219 "content-type": "application/json; charset=utf-8",
220 "x-github-event": "push",
221 "x-hub-signature-256": await signGitHubPayload("github-missing-header-secret", missingHeaderBody),
222 });
223 expect(missingHeader.status).toBe(401);
224 
225 const invalidTokenContext = await createOwnedProjectContext({
226 user: {
227 email: "webhook-public-gitlab-invalid-token@example.com",
228 slug: "webhook-public-gitlab-invalid-token",
229 },
230 project: {
231 projectSlug: "gitlab-invalid-token-project",
232 repoUrl: "https://gitlab.com/example/gitlab-invalid-token-project",
233 },
234 });
235 
236 await putWebhook(invalidTokenContext.sessionId, invalidTokenContext.project.id, "gitlab", {
237 enabled: true,
238 config: null,
239 secret: "gitlab-invalid-token-secret",
240 });
241 
242 const invalidTokenBody = JSON.stringify({
243 object_kind: "push",
244 event_name: "push",
245 ref: "refs/heads/main",
246 before: BEFORE_SHA,
247 after: AFTER_SHA,
248 checkout_sha: AFTER_SHA,
249 project: buildGitLabProjectPayload(
250 invalidTokenContext.project.repoUrl,
251 invalidTokenContext.project.defaultBranch,
252 ),
253 repository: buildGitLabRepositoryPayload(invalidTokenContext.project.repoUrl),
254 });
255 
256 const invalidToken = await postPublicWebhook("gitlab", invalidTokenContext.project, invalidTokenBody, {
257 "content-type": "application/json; charset=utf-8",
258 "x-gitlab-event": "Push Hook",
259 "x-gitlab-token": "gitlab-wrong-token",
260 "idempotency-key": "gitlab-invalid-token",
261 });
262 expect(invalidToken.status).toBe(401);
263 
264 const invalidSignatureRows = await readProjectDoRows(invalidSignatureContext.project.id);
265 expect(invalidSignatureRows.runs).toEqual([]);
266 expect(invalidSignatureRows.webhookDeliveries).toEqual([]);
267 
268 const repoMismatchRows = await readProjectDoRows(repoMismatchContext.project.id);
269 expect(repoMismatchRows.runs).toEqual([]);
270 expect(repoMismatchRows.webhookDeliveries).toEqual([]);
271 
272 const missingHeaderRows = await readProjectDoRows(missingHeaderContext.project.id);
273 expect(missingHeaderRows.runs).toEqual([]);
274 expect(missingHeaderRows.webhookDeliveries).toEqual([]);
275 
276 const invalidTokenRows = await readProjectDoRows(invalidTokenContext.project.id);
277 expect(invalidTokenRows.runs).toEqual([]);
278 expect(invalidTokenRows.webhookDeliveries).toEqual([]);
279 });
280 
281 it("rejects webhook delivery with an invalid HMAC signature", async () => {
282 const context = await createOwnedProjectContext({
283 user: {
284 email: "webhook-public-invalid-hmac@example.com",
285 slug: "webhook-public-invalid-hmac",
286 },
287 project: {
288 projectSlug: "invalid-hmac-project",
289 repoUrl: "https://github.com/example/invalid-hmac-project",
290 },
291 });
292 
293 await putWebhook(context.sessionId, context.project.id, "github", {
294 enabled: true,
295 secret: "correct-secret",
296 });
297 
298 const body = JSON.stringify({
299 ref: "refs/heads/main",
300 before: BEFORE_SHA,
301 after: AFTER_SHA,
302 head_commit: {
303 id: AFTER_SHA,
304 },
305 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
306 });
307 
308 const response = await postPublicWebhook("github", context.project, body, {
309 "content-type": "application/json; charset=utf-8",
310 "x-github-event": "push",
311 "x-github-delivery": "invalid-hmac-delivery",
312 "x-hub-signature-256": await signGitHubPayload("wrong-secret", body),
313 });
314 expect(response.status).toBe(401);
315 
316 const rows = await readProjectDoRows(context.project.id);
317 expect(rows.runs).toEqual([]);
318 expect(rows.webhookDeliveries).toEqual([]);
319 });
320 
321 it("rejects webhook delivery with an invalid GitLab shared secret", async () => {
322 const context = await createOwnedProjectContext({
323 user: {
324 email: "webhook-public-invalid-gitlab-token@example.com",
325 slug: "webhook-public-invalid-gitlab-token",
326 },
327 project: {
328 projectSlug: "invalid-gitlab-token-project",
329 repoUrl: "https://gitlab.com/example/invalid-gitlab-token-project",
330 },
331 });
332 
333 await putWebhook(context.sessionId, context.project.id, "gitlab", {
334 enabled: true,
335 config: null,
336 secret: "correct-gitlab-secret",
337 });
338 
339 const body = JSON.stringify({
340 object_kind: "push",
341 event_name: "push",
342 ref: "refs/heads/main",
343 before: BEFORE_SHA,
344 after: AFTER_SHA,
345 checkout_sha: AFTER_SHA,
346 project: buildGitLabProjectPayload(context.project.repoUrl, context.project.defaultBranch),
347 repository: buildGitLabRepositoryPayload(context.project.repoUrl),
348 });
349 
350 const response = await postPublicWebhook("gitlab", context.project, body, {
351 "content-type": "application/json; charset=utf-8",
352 "x-gitlab-event": "Push Hook",
353 "x-gitlab-token": "wrong-secret",
354 "idempotency-key": "invalid-gitlab-token-delivery",
355 });
356 expect(response.status).toBe(401);
357 
358 const rows = await readProjectDoRows(context.project.id);
359 expect(rows.runs).toEqual([]);
360 expect(rows.webhookDeliveries).toEqual([]);
361 });
362 
363 it("rejects webhook delivery with missing required signature header", async () => {
364 const context = await createOwnedProjectContext({
365 user: {
366 email: "webhook-public-missing-sig@example.com",
367 slug: "webhook-public-missing-sig",
368 },
369 project: {
370 projectSlug: "missing-sig-project",
371 repoUrl: "https://github.com/example/missing-sig-project",
372 },
373 });
374 
375 await putWebhook(context.sessionId, context.project.id, "github", {
376 enabled: true,
377 secret: "missing-sig-secret",
378 });
379 
380 const body = JSON.stringify({
381 ref: "refs/heads/main",
382 before: BEFORE_SHA,
383 after: AFTER_SHA,
384 head_commit: {
385 id: AFTER_SHA,
386 },
387 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
388 });
389 
390 const response = await postPublicWebhook("github", context.project, body, {
391 "content-type": "application/json; charset=utf-8",
392 "x-github-event": "push",
393 "x-github-delivery": "missing-sig-delivery",
394 });
395 expect(response.status).toBe(401);
396 });
397 
398 it("rejects webhook delivery with non-JSON content type", async () => {
399 const context = await createOwnedProjectContext({
400 user: {
401 email: "webhook-public-non-json-ct@example.com",
402 slug: "webhook-public-non-json-ct",
403 },
404 project: {
405 projectSlug: "non-json-ct-project",
406 repoUrl: "https://github.com/example/non-json-ct-project",
407 },
408 });
409 
410 await putWebhook(context.sessionId, context.project.id, "github", {
411 enabled: true,
412 secret: "content-type-secret",
413 });
414 
415 const body = JSON.stringify({
416 ref: "refs/heads/main",
417 before: BEFORE_SHA,
418 after: AFTER_SHA,
419 head_commit: {
420 id: AFTER_SHA,
421 },
422 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
423 });
424 
425 const response = await postPublicWebhook("github", context.project, body, {
426 "content-type": "text/plain",
427 "x-github-event": "push",
428 "x-github-delivery": "non-json-ct-delivery",
429 "x-hub-signature-256": await signGitHubPayload("content-type-secret", body),
430 });
431 expect(response.status).toBe(415);
432 });
433 
434 it("rejects webhook delivery to a disabled webhook with 404", async () => {
435 const context = await createOwnedProjectContext({
436 user: {
437 email: "webhook-public-disabled-reject@example.com",
438 slug: "webhook-public-disabled-reject",
439 },
440 project: {
441 projectSlug: "disabled-reject-project",
442 repoUrl: "https://github.com/example/disabled-reject-project",
443 },
444 });
445 
446 await putWebhook(context.sessionId, context.project.id, "github", {
447 enabled: true,
448 secret: "disabled-secret",
449 });
450 
451 await putWebhook(context.sessionId, context.project.id, "github", {
452 enabled: false,
453 });
454 
455 const body = JSON.stringify({
456 ref: "refs/heads/main",
457 before: BEFORE_SHA,
458 after: AFTER_SHA,
459 head_commit: {
460 id: AFTER_SHA,
461 },
462 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
463 });
464 
465 const response = await postPublicWebhook("github", context.project, body, {
466 "content-type": "application/json; charset=utf-8",
467 "x-github-event": "push",
468 "x-github-delivery": "disabled-reject-delivery",
469 "x-hub-signature-256": await signGitHubPayload("disabled-secret", body),
470 });
471 expect(response.status).toBe(404);
472 
473 const rows = await readProjectDoRows(context.project.id);
474 expect(rows.runs).toEqual([]);
475 expect(rows.webhookDeliveries).toEqual([]);
476 });
477 
478 it("rejects webhook delivery when payload repository does not match project", async () => {
479 const context = await createOwnedProjectContext({
480 user: {
481 email: "webhook-public-repo-mismatch@example.com",
482 slug: "webhook-public-repo-mismatch",
483 },
484 project: {
485 projectSlug: "repo-mismatch-project",
486 repoUrl: "https://github.com/example/correct-repo",
487 },
488 });
489 
490 await putWebhook(context.sessionId, context.project.id, "github", {
491 enabled: true,
492 secret: "mismatch-secret",
493 });
494 
495 const body = JSON.stringify({
496 ref: "refs/heads/main",
497 before: BEFORE_SHA,
498 after: AFTER_SHA,
499 head_commit: {
500 id: AFTER_SHA,
501 },
502 repository: buildGitHubRepository("https://github.com/example/wrong-repo", context.project.defaultBranch),
503 });
504 
505 const response = await postPublicWebhook("github", context.project, body, {
506 "content-type": "application/json; charset=utf-8",
507 "x-github-event": "push",
508 "x-github-delivery": "repo-mismatch-delivery",
509 "x-hub-signature-256": await signGitHubPayload("mismatch-secret", body),
510 });
511 expect(response.status).toBe(403);
512 
513 const rows = await readProjectDoRows(context.project.id);
514 expect(rows.runs).toEqual([]);
515 expect(rows.webhookDeliveries).toEqual([]);
516 });
517 });
518});