Skip to content
File

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

typescript299 lines
1import { BranchName } from "@/contracts";
2import { describe, expect, it } from "vitest";
3 
4import { readProjectDoRows } from "../../../helpers/runtime";
5import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks";
6 
7import {
8 buildGitHubRepository,
9 buildGitLabProjectPayload,
10 buildGitLabRepositoryPayload,
11 createOwnedProjectContext,
12 patchProject,
13 postPublicWebhook,
14 putWebhook,
15 signGitHubPayload,
16 signGiteaPayload,
17} from "../helpers";
18import { AFTER_SHA, BEFORE_SHA, THIRD_SHA, fillProjectQueueToCapacity, freeOneQueuedSlot } from "../public-helpers";
19 
20describe("webhook public routes", () => {
21 registerWorkerRuntimeHooks();
22 
23 describe("redelivery and queue pressure", () => {
24 it("returns 200 for GitLab queue_full and accepts a later redelivery with the same delivery id", async () => {
25 const context = await createOwnedProjectContext({
26 user: {
27 email: "webhook-public-gitlab-queue@example.com",
28 slug: "webhook-public-gitlab-queue",
29 },
30 project: {
31 projectSlug: "gitlab-queue-project",
32 repoUrl: "https://gitlab.com/example/gitlab-queue-project",
33 },
34 });
35 
36 await putWebhook(context.sessionId, context.project.id, "gitlab", {
37 enabled: true,
38 config: null,
39 secret: "gitlab-queue-secret",
40 });
41 
42 await fillProjectQueueToCapacity({
43 projectId: context.project.id,
44 userId: context.user.id,
45 branch: context.project.defaultBranch,
46 });
47 
48 const body = JSON.stringify({
49 object_kind: "push",
50 event_name: "push",
51 ref: "refs/heads/main",
52 before: AFTER_SHA,
53 after: THIRD_SHA,
54 checkout_sha: THIRD_SHA,
55 project: buildGitLabProjectPayload(context.project.repoUrl, context.project.defaultBranch),
56 repository: buildGitLabRepositoryPayload(context.project.repoUrl),
57 });
58 const headers = {
59 "content-type": "application/json; charset=utf-8",
60 "x-gitlab-event": "Push Hook",
61 "x-gitlab-token": "gitlab-queue-secret",
62 "x-gitlab-instance": "https://gitlab.com",
63 "idempotency-key": "gitlab-delivery-queue-full",
64 };
65 
66 const queueFull = await postPublicWebhook("gitlab", context.project, body, headers);
67 expect(queueFull.status).toBe(200);
68 expect(queueFull.response.headers.get("retry-after")).toBeNull();
69 
70 const afterQueueFull = await readProjectDoRows(context.project.id);
71 expect(afterQueueFull.runs).toHaveLength(20);
72 expect(afterQueueFull.webhookDeliveries).toHaveLength(1);
73 expect(afterQueueFull.webhookDeliveries[0]?.outcome).toBe("queue_full");
74 expect(afterQueueFull.webhookDeliveries[0]?.runId).toBeNull();
75 
76 const firstRunId = afterQueueFull.runs[0]?.runId;
77 expect(firstRunId).toBeTruthy();
78 await freeOneQueuedSlot(context.project.id, firstRunId!);
79 
80 const retried = await postPublicWebhook("gitlab", context.project, body, headers);
81 expect(retried.status).toBe(202);
82 
83 const rows = await readProjectDoRows(context.project.id);
84 expect(rows.runs).toHaveLength(21);
85 expect(rows.webhookDeliveries).toHaveLength(1);
86 expect(rows.webhookDeliveries[0]?.outcome).toBe("accepted");
87 expect(rows.webhookDeliveries[0]?.deliveryId).toBe("gitlab-delivery-queue-full");
88 expect(rows.webhookDeliveries[0]?.runId).toEqual(expect.any(String));
89 expect(
90 rows.runs.some((row) => row.provider === "gitlab" && row.deliveryId === "gitlab-delivery-queue-full"),
91 ).toBe(true);
92 });
93 
94 it("returns 503 for queue_full and accepts a later GitHub redelivery with the same delivery id", async () => {
95 const context = await createOwnedProjectContext({
96 user: {
97 email: "webhook-public-github-queue@example.com",
98 slug: "webhook-public-github-queue",
99 },
100 project: {
101 projectSlug: "github-queue-project",
102 repoUrl: "https://github.com/example/github-queue-project",
103 },
104 });
105 
106 await putWebhook(context.sessionId, context.project.id, "github", {
107 enabled: true,
108 secret: "github-queue-secret",
109 });
110 
111 await fillProjectQueueToCapacity({
112 projectId: context.project.id,
113 userId: context.user.id,
114 branch: context.project.defaultBranch,
115 });
116 
117 const body = JSON.stringify({
118 ref: "refs/heads/main",
119 before: AFTER_SHA,
120 after: THIRD_SHA,
121 head_commit: {
122 id: THIRD_SHA,
123 },
124 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
125 });
126 const headers = {
127 "content-type": "application/json; charset=utf-8",
128 "x-github-event": "push",
129 "x-github-delivery": "github-delivery-queue-full",
130 "x-hub-signature-256": await signGitHubPayload("github-queue-secret", body),
131 };
132 
133 const queueFull = await postPublicWebhook("github", context.project, body, headers);
134 expect(queueFull.status).toBe(503);
135 expect(queueFull.response.headers.get("retry-after")).toBe("60");
136 
137 const afterQueueFull = await readProjectDoRows(context.project.id);
138 expect(afterQueueFull.runs).toHaveLength(20);
139 expect(afterQueueFull.webhookDeliveries).toHaveLength(1);
140 expect(afterQueueFull.webhookDeliveries[0]?.outcome).toBe("queue_full");
141 expect(afterQueueFull.webhookDeliveries[0]?.runId).toBeNull();
142 
143 const firstRunId = afterQueueFull.runs[0]?.runId;
144 expect(firstRunId).toBeTruthy();
145 await freeOneQueuedSlot(context.project.id, firstRunId!);
146 
147 const retried = await postPublicWebhook("github", context.project, body, headers);
148 expect(retried.status).toBe(202);
149 
150 const rows = await readProjectDoRows(context.project.id);
151 expect(rows.runs).toHaveLength(21);
152 expect(rows.webhookDeliveries).toHaveLength(1);
153 expect(rows.webhookDeliveries[0]?.outcome).toBe("accepted");
154 expect(rows.webhookDeliveries[0]?.deliveryId).toBe("github-delivery-queue-full");
155 expect(rows.webhookDeliveries[0]?.runId).toEqual(expect.any(String));
156 expect(
157 rows.runs.some((row) => row.provider === "github" && row.deliveryId === "github-delivery-queue-full"),
158 ).toBe(true);
159 });
160 
161 it("returns 503 for Gitea queue_full and accepts a later redelivery with the same delivery id", async () => {
162 const context = await createOwnedProjectContext({
163 user: {
164 email: "webhook-public-gitea-queue@example.com",
165 slug: "webhook-public-gitea-queue",
166 },
167 project: {
168 projectSlug: "gitea-queue-project",
169 repoUrl: "https://gitea.example.com:8443/git/example/gitea-queue-project",
170 },
171 });
172 
173 await putWebhook(context.sessionId, context.project.id, "gitea", {
174 enabled: true,
175 config: {
176 instanceUrl: "https://gitea.example.com:8443/git",
177 },
178 secret: "gitea-queue-secret",
179 });
180 
181 await fillProjectQueueToCapacity({
182 projectId: context.project.id,
183 userId: context.user.id,
184 branch: context.project.defaultBranch,
185 });
186 
187 const body = JSON.stringify({
188 ref: "refs/heads/main",
189 before: AFTER_SHA,
190 after: THIRD_SHA,
191 repository: {
192 clone_url: `${context.project.repoUrl}.git`,
193 default_branch: context.project.defaultBranch,
194 },
195 });
196 const signature = await signGiteaPayload("gitea-queue-secret", body);
197 const deliveryId = "gitea-delivery-queue-full";
198 const headers = {
199 "content-type": "application/json; charset=utf-8",
200 "x-gitea-event": "push",
201 "x-gitea-delivery": deliveryId,
202 "x-gitea-signature": signature,
203 };
204 
205 const queueFull = await postPublicWebhook("gitea", context.project, body, headers);
206 expect(queueFull.status).toBe(503);
207 expect(queueFull.response.headers.get("retry-after")).toBe("60");
208 
209 const afterQueueFull = await readProjectDoRows(context.project.id);
210 expect(afterQueueFull.runs).toHaveLength(20);
211 expect(afterQueueFull.webhookDeliveries).toHaveLength(1);
212 expect(afterQueueFull.webhookDeliveries[0]?.outcome).toBe("queue_full");
213 expect(afterQueueFull.webhookDeliveries[0]?.runId).toBeNull();
214 
215 const firstRunId = afterQueueFull.runs[0]?.runId;
216 expect(firstRunId).toBeTruthy();
217 await freeOneQueuedSlot(context.project.id, firstRunId!);
218 
219 const retried = await postPublicWebhook("gitea", context.project, body, headers);
220 expect(retried.status).toBe(202);
221 
222 const rows = await readProjectDoRows(context.project.id);
223 expect(rows.runs).toHaveLength(21);
224 expect(rows.webhookDeliveries).toHaveLength(1);
225 expect(rows.webhookDeliveries[0]?.outcome).toBe("accepted");
226 expect(rows.webhookDeliveries[0]?.deliveryId).toBe(deliveryId);
227 expect(rows.webhookDeliveries[0]?.runId).toEqual(expect.any(String));
228 expect(rows.runs.some((row) => row.provider === "gitea" && row.deliveryId === deliveryId)).toBe(true);
229 });
230 
231 it("replays a stored queue_full GitHub delivery instead of overwriting it after branch drift", async () => {
232 const context = await createOwnedProjectContext({
233 user: {
234 email: "webhook-public-github-queue-replay@example.com",
235 slug: "webhook-public-github-queue-replay",
236 },
237 project: {
238 projectSlug: "github-queue-replay-project",
239 repoUrl: "https://github.com/example/github-queue-replay-project",
240 },
241 });
242 
243 await putWebhook(context.sessionId, context.project.id, "github", {
244 enabled: true,
245 secret: "github-queue-replay-secret",
246 });
247 
248 await fillProjectQueueToCapacity({
249 projectId: context.project.id,
250 userId: context.user.id,
251 branch: context.project.defaultBranch,
252 });
253 
254 const body = JSON.stringify({
255 ref: "refs/heads/main",
256 before: AFTER_SHA,
257 after: THIRD_SHA,
258 head_commit: {
259 id: THIRD_SHA,
260 },
261 repository: buildGitHubRepository(context.project.repoUrl, context.project.defaultBranch),
262 });
263 const headers = {
264 "content-type": "application/json; charset=utf-8",
265 "x-github-event": "push",
266 "x-github-delivery": "github-delivery-queue-replay",
267 "x-hub-signature-256": await signGitHubPayload("github-queue-replay-secret", body),
268 };
269 
270 const queueFull = await postPublicWebhook("github", context.project, body, headers);
271 expect(queueFull.status).toBe(503);
272 expect(queueFull.response.headers.get("retry-after")).toBe("60");
273 
274 const afterQueueFull = await readProjectDoRows(context.project.id);
275 expect(afterQueueFull.webhookDeliveries).toHaveLength(1);
276 expect(afterQueueFull.webhookDeliveries[0]?.outcome).toBe("queue_full");
277 
278 const firstRunId = afterQueueFull.runs[0]?.runId;
279 expect(firstRunId).toBeTruthy();
280 await freeOneQueuedSlot(context.project.id, firstRunId!);
281 
282 const drifted = await patchProject(context.sessionId, context.project.id, {
283 defaultBranch: BranchName.assertDecode("develop"),
284 });
285 expect(drifted.status).toBe(200);
286 
287 const retried = await postPublicWebhook("github", context.project, body, headers);
288 expect(retried.status).toBe(503);
289 expect(retried.response.headers.get("retry-after")).toBe("60");
290 
291 const rows = await readProjectDoRows(context.project.id);
292 expect(rows.runs).toHaveLength(20);
293 expect(rows.webhookDeliveries).toHaveLength(1);
294 expect(rows.webhookDeliveries[0]?.deliveryId).toBe("github-delivery-queue-replay");
295 expect(rows.webhookDeliveries[0]?.outcome).toBe("queue_full");
296 });
297 });
298});