Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

crontech-deploy.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

crontech-deploy.test.tsBlame333 lines · 1 contributor
43cf9b0Claude1/**
ba93444Claude2 * BLK-016 — Crontech deploy webhook sender.
43cf9b0Claude3 *
ba93444Claude4 * Asserts that `triggerCrontechDeploy` (in `src/hooks/post-receive.ts`)
5 * matches the wire contract documented at the top of that helper, which
6 * is the inbound contract for Crontech's
7 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
43cf9b0Claude8 *
ba93444Claude9 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude10 * Content-Type: application/json
ba93444Claude11 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, secret))>
12 *
13 * body = {
14 * event: "push",
15 * repository: { full_name },
16 * ref, after, before,
17 * pusher: { name, email },
18 * commits: [...]
19 * }
20 *
21 * Plus at-least-once delivery: 5 attempts on 5xx with exponential backoff,
22 * stop on first 2xx or unrecoverable 4xx.
43cf9b0Claude23 *
24 * The helper swallows DB errors, so these tests work without a real DB.
25 */
26
27import { afterEach, beforeEach, describe, expect, it } from "bun:test";
ba93444Claude28import { createHmac } from "crypto";
43cf9b0Claude29import { __test } from "../hooks/post-receive";
30
ba93444Claude31const { triggerCrontechDeploy, signBody } = __test;
43cf9b0Claude32
33interface CapturedCall {
34 url: string;
35 init: RequestInit;
36}
37
38const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
39const origUrl = process.env.CRONTECH_DEPLOY_URL;
ba93444Claude40const origRepo = process.env.CRONTECH_REPO;
41
42const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
43const ZERO_SHA = "0000000000000000000000000000000000000000";
43cf9b0Claude44
ba93444Claude45function makeArgs(overrides: Partial<{
46 owner: string;
47 repo: string;
48 before: string;
49 after: string;
50 ref: string;
51 branch: string;
52 repositoryId: string;
53}> = {}) {
54 return {
55 owner: "ccantynz-alt",
56 repo: "crontech",
57 before: ZERO_SHA,
58 after: "a".repeat(40),
59 ref: "refs/heads/Main",
60 branch: "Main",
61 repositoryId: NULL_REPO_ID,
62 ...overrides,
63 };
64}
65
66function captureFetch(
67 responder: (callIdx: number) => Response | Promise<Response> = () =>
43cf9b0Claude68 new Response(
ba93444Claude69 JSON.stringify({ ok: true, deploymentId: "d1" }),
43cf9b0Claude70 { status: 200, headers: { "Content-Type": "application/json" } }
71 )
ba93444Claude72): { calls: CapturedCall[]; fn: typeof fetch } {
43cf9b0Claude73 const calls: CapturedCall[] = [];
ba93444Claude74 const fn = (async (
43cf9b0Claude75 input: RequestInfo | URL,
76 init: RequestInit = {}
77 ): Promise<Response> => {
ba93444Claude78 const i = calls.length;
43cf9b0Claude79 calls.push({ url: String(input), init });
ba93444Claude80 return responder(i);
81 }) as unknown as typeof fetch;
82 return { calls, fn };
43cf9b0Claude83}
84
ba93444Claude85const noSleep = async (_ms: number) => {};
86
87describe("hooks/post-receive — signBody", () => {
88 it("returns null when no secret", () => {
89 expect(signBody("any body", "")).toBeNull();
90 });
91
92 it("produces sha256=<hex hmac>", () => {
93 const body = '{"event":"push"}';
94 const secret = "topsecret";
95 const expected =
96 "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
97 expect(signBody(body, secret)).toBe(expected);
98 });
99
100 it("is deterministic for the same input", () => {
101 const a = signBody("body", "k");
102 const b = signBody("body", "k");
103 expect(a).toBe(b);
104 });
43cf9b0Claude105
ba93444Claude106 it("changes when the body changes", () => {
107 const a = signBody("body1", "k");
108 const b = signBody("body2", "k");
109 expect(a).not.toBe(b);
110 });
111});
112
113describe("hooks/post-receive — triggerCrontechDeploy (BLK-016 sender)", () => {
43cf9b0Claude114 beforeEach(() => {
115 delete process.env.GLUECRON_WEBHOOK_SECRET;
116 delete process.env.CRONTECH_DEPLOY_URL;
ba93444Claude117 delete process.env.CRONTECH_REPO;
43cf9b0Claude118 });
119
120 afterEach(() => {
121 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
122 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
123 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
124 else process.env.CRONTECH_DEPLOY_URL = origUrl;
ba93444Claude125 if (origRepo === undefined) delete process.env.CRONTECH_REPO;
126 else process.env.CRONTECH_REPO = origRepo;
43cf9b0Claude127 });
128
129 it("is exported from __test", () => {
130 expect(typeof triggerCrontechDeploy).toBe("function");
131 });
132
ba93444Claude133 it("POSTs to /api/webhooks/gluecron-push (matches Crontech receiver path)", async () => {
134 const { calls, fn } = captureFetch();
43cf9b0Claude135
ba93444Claude136 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
43cf9b0Claude137
138 expect(calls.length).toBe(1);
139 expect(calls[0]!.url).toBe(
ba93444Claude140 "https://crontech.ai/api/webhooks/gluecron-push"
43cf9b0Claude141 );
ba93444Claude142 expect(calls[0]!.url).not.toContain("/api/hooks/gluecron/push");
43cf9b0Claude143 expect(calls[0]!.init.method).toBe("POST");
144 });
145
ba93444Claude146 it("respects CRONTECH_DEPLOY_URL override", async () => {
147 process.env.CRONTECH_DEPLOY_URL =
148 "https://staging.crontech.ai/api/webhooks/gluecron-push";
149 const { calls, fn } = captureFetch();
150
151 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
152
153 expect(calls[0]!.url).toBe(
154 "https://staging.crontech.ai/api/webhooks/gluecron-push"
155 );
156 });
157
158 it("sends GitHub-shaped payload (event, repository.full_name, ref, after, before, pusher, commits)", async () => {
159 const { calls, fn } = captureFetch();
160 const after = "d".repeat(40);
161 const before = "c".repeat(40);
43cf9b0Claude162
163 await triggerCrontechDeploy(
ba93444Claude164 makeArgs({
165 owner: "acme",
166 repo: "api",
167 after,
168 before,
169 ref: "refs/heads/Main",
170 branch: "Main",
171 }),
172 { fetchImpl: fn, sleep: noSleep }
43cf9b0Claude173 );
174
ba93444Claude175 const body = JSON.parse(String(calls[0]!.init.body));
176 expect(body.event).toBe("push");
177 expect(body.repository).toEqual({ full_name: "acme/api" });
178 expect(body.ref).toBe("refs/heads/Main");
179 expect(body.after).toBe(after);
180 expect(body.before).toBe(before);
181 expect(body.pusher).toBeDefined();
182 expect(typeof body.pusher.name).toBe("string");
183 expect(typeof body.pusher.email).toBe("string");
184 expect(Array.isArray(body.commits)).toBe(true);
185 expect(typeof body.sent_at).toBe("string");
186 expect(new Date(body.sent_at).toString()).not.toBe("Invalid Date");
187 expect(body.source).toBe("gluecron");
188 });
189
190 it("signs the body with HMAC-SHA256 in X-Gluecron-Signature when secret is set", async () => {
191 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
192 const { calls, fn } = captureFetch();
193
194 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
195
43cf9b0Claude196 const headers = calls[0]!.init.headers as Record<string, string>;
ba93444Claude197 const sentBody = String(calls[0]!.init.body);
198 const expected =
199 "sha256=" +
200 createHmac("sha256", "shared-vultr-secret")
201 .update(sentBody)
202 .digest("hex");
203 expect(headers["X-Gluecron-Signature"]).toBe(expected);
43cf9b0Claude204 expect(headers["Content-Type"]).toBe("application/json");
205 });
206
ba93444Claude207 it("omits X-Gluecron-Signature when no secret is configured", async () => {
208 const { calls, fn } = captureFetch();
43cf9b0Claude209
ba93444Claude210 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
43cf9b0Claude211
212 const headers = calls[0]!.init.headers as Record<string, string>;
ba93444Claude213 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
43cf9b0Claude214 });
215
ba93444Claude216 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
217 const { calls, fn } = captureFetch();
218
219 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
220
221 const headers = calls[0]!.init.headers as Record<string, string>;
222 expect(headers["X-Gluecron-Event"]).toBe("push");
223 expect(headers["X-Gluecron-Delivery"]).toBeDefined();
224 expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0);
225 });
226
227 it("ref carries the actual case of the branch (Main, not main)", async () => {
228 const { calls, fn } = captureFetch();
43cf9b0Claude229
230 await triggerCrontechDeploy(
ba93444Claude231 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
232 { fetchImpl: fn, sleep: noSleep }
43cf9b0Claude233 );
234
235 const body = JSON.parse(String(calls[0]!.init.body));
ba93444Claude236 expect(body.ref).toBe("refs/heads/Main");
237 expect(body.ref).not.toBe("refs/heads/main");
43cf9b0Claude238 });
239
ba93444Claude240 it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => {
241 const responses = [
242 new Response("", { status: 502 }),
243 new Response("", { status: 503 }),
244 new Response("", { status: 200 }),
245 ];
246 const { calls, fn } = captureFetch((i) => responses[i]!);
247 const sleeps: number[] = [];
43cf9b0Claude248
ba93444Claude249 await triggerCrontechDeploy(makeArgs(), {
250 fetchImpl: fn,
251 sleep: async (ms) => { sleeps.push(ms); },
252 retryDelaysMs: [10, 20, 30, 40, 50],
253 });
254
255 expect(calls.length).toBe(3);
256 // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd.
257 expect(sleeps).toEqual([10, 20]);
258 });
259
260 it("gives up after the configured number of attempts on persistent 5xx", async () => {
261 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
262 const sleeps: number[] = [];
263
264 await triggerCrontechDeploy(makeArgs(), {
265 fetchImpl: fn,
266 sleep: async (ms) => { sleeps.push(ms); },
267 retryDelaysMs: [1, 2, 3, 4, 5],
268 });
269
270 // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once).
271 expect(calls.length).toBe(6);
272 expect(sleeps).toEqual([1, 2, 3, 4, 5]);
273 });
274
275 it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => {
276 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
277 const sleeps: number[] = [];
278
279 await triggerCrontechDeploy(makeArgs(), {
280 fetchImpl: fn,
281 sleep: async (ms) => { sleeps.push(ms); },
282 retryDelaysMs: [1, 2, 3, 4, 5],
283 });
43cf9b0Claude284
285 expect(calls.length).toBe(1);
ba93444Claude286 expect(sleeps).toEqual([]);
43cf9b0Claude287 });
288
ba93444Claude289 it("does retry 408 (request timeout) and 429 (rate limit)", async () => {
290 const responses = [
291 new Response("", { status: 429 }),
292 new Response("", { status: 408 }),
293 new Response("", { status: 200 }),
294 ];
295 const { calls, fn } = captureFetch((i) => responses[i]!);
296
297 await triggerCrontechDeploy(makeArgs(), {
298 fetchImpl: fn,
299 sleep: noSleep,
300 retryDelaysMs: [1, 2, 3, 4, 5],
301 });
302
303 expect(calls.length).toBe(3);
304 });
305
306 it("retries on network errors (fetch throws)", async () => {
307 let callCount = 0;
308 const fn = (async () => {
309 callCount++;
310 if (callCount < 3) throw new Error("ECONNREFUSED");
311 return new Response("", { status: 200 });
312 }) as unknown as typeof fetch;
313
314 await triggerCrontechDeploy(makeArgs(), {
315 fetchImpl: fn,
316 sleep: noSleep,
317 retryDelaysMs: [1, 2, 3, 4, 5],
318 });
319
320 expect(callCount).toBe(3);
321 });
322
323 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
324 const { fn } = captureFetch(() => new Response("", { status: 401 }));
43cf9b0Claude325 await expect(
ba93444Claude326 triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
43cf9b0Claude327 ).resolves.toBeUndefined();
ba93444Claude328 });
329
330 it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => {
331 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
43cf9b0Claude332 });
333});