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

vapron-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.

vapron-deploy.test.tsBlame406 lines · 3 contributors
43cf9b0Claude1/**
9ecf5a4Claude2 * BLK-016 — Vapron deploy webhook sender.
43cf9b0Claude3 *
9ecf5a4Claude4 * Asserts that `triggerVapronDeploy` (in `src/hooks/post-receive.ts`)
ba93444Claude5 * matches the wire contract documented at the top of that helper, which
9ecf5a4Claude6 * is the inbound contract for Vapron's
ba93444Claude7 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
43cf9b0Claude8 *
9ecf5a4Claude9 * POST https://vapron.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";
9ecf5a4Claude30import { config } from "../lib/config";
43cf9b0Claude31
9ecf5a4Claude32const { triggerVapronDeploy, signBody } = __test;
43cf9b0Claude33
34interface CapturedCall {
35 url: string;
36 init: RequestInit;
37}
38
39const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
9ecf5a4Claude40const origUrl = process.env.VAPRON_DEPLOY_URL;
41const origRepo = process.env.VAPRON_REPO;
ba93444Claude42
43const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
44const ZERO_SHA = "0000000000000000000000000000000000000000";
43cf9b0Claude45
ba93444Claude46function makeArgs(overrides: Partial<{
47 owner: string;
48 repo: string;
49 before: string;
50 after: string;
51 ref: string;
52 branch: string;
53 repositoryId: string;
54}> = {}) {
55 return {
56 owner: "ccantynz-alt",
9ecf5a4Claude57 repo: "vapron",
ba93444Claude58 before: ZERO_SHA,
59 after: "a".repeat(40),
60 ref: "refs/heads/Main",
61 branch: "Main",
62 repositoryId: NULL_REPO_ID,
63 ...overrides,
64 };
65}
43cf9b0Claude66
ba93444Claude67function captureFetch(
68 responder: (callIdx: number) => Response | Promise<Response> = () =>
43cf9b0Claude69 new Response(
ba93444Claude70 JSON.stringify({ ok: true, deploymentId: "d1" }),
43cf9b0Claude71 { status: 200, headers: { "Content-Type": "application/json" } }
72 )
ba93444Claude73): { calls: CapturedCall[]; fn: typeof fetch } {
43cf9b0Claude74 const calls: CapturedCall[] = [];
ba93444Claude75 const fn = (async (
43cf9b0Claude76 input: RequestInfo | URL,
77 init: RequestInit = {}
78 ): Promise<Response> => {
ba93444Claude79 const i = calls.length;
43cf9b0Claude80 calls.push({ url: String(input), init });
ba93444Claude81 return responder(i);
82 }) as unknown as typeof fetch;
83 return { calls, fn };
43cf9b0Claude84}
85
ba93444Claude86const noSleep = async (_ms: number) => {};
87
88describe("hooks/post-receive — signBody", () => {
89 it("returns null when no secret", () => {
90 expect(signBody("any body", "")).toBeNull();
91 });
92
93 it("produces sha256=<hex hmac>", () => {
94 const body = '{"event":"push"}';
95 const secret = "topsecret";
96 const expected =
97 "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
98 expect(signBody(body, secret)).toBe(expected);
99 });
100
101 it("is deterministic for the same input", () => {
102 const a = signBody("body", "k");
103 const b = signBody("body", "k");
104 expect(a).toBe(b);
105 });
43cf9b0Claude106
ba93444Claude107 it("changes when the body changes", () => {
108 const a = signBody("body1", "k");
109 const b = signBody("body2", "k");
110 expect(a).not.toBe(b);
111 });
112});
43cf9b0Claude113
9ecf5a4Claude114describe("hooks/post-receive — triggerVapronDeploy (BLK-016 sender)", () => {
43cf9b0Claude115 beforeEach(() => {
116 delete process.env.GLUECRON_WEBHOOK_SECRET;
9ecf5a4Claude117 delete process.env.VAPRON_DEPLOY_URL;
118 delete process.env.VAPRON_REPO;
119 delete process.env.VAPRON_HMAC_SECRET;
120 // legacy names must not leak into the default-URL assertions
43cf9b0Claude121 delete process.env.CRONTECH_DEPLOY_URL;
ba93444Claude122 delete process.env.CRONTECH_REPO;
9ecf5a4Claude123 delete process.env.CRONTECH_HMAC_SECRET;
43cf9b0Claude124 });
125
126 afterEach(() => {
127 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
128 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
9ecf5a4Claude129 if (origUrl === undefined) delete process.env.VAPRON_DEPLOY_URL;
130 else process.env.VAPRON_DEPLOY_URL = origUrl;
131 if (origRepo === undefined) delete process.env.VAPRON_REPO;
132 else process.env.VAPRON_REPO = origRepo;
43cf9b0Claude133 });
134
135 it("is exported from __test", () => {
9ecf5a4Claude136 expect(typeof triggerVapronDeploy).toBe("function");
43cf9b0Claude137 });
138
5e67f6bccanty labs139 it("POSTs to /api/hooks/gluecron/push (matches Vapron receiver path, verified live 2026-07-14)", async () => {
ba93444Claude140 const { calls, fn } = captureFetch();
43cf9b0Claude141
9ecf5a4Claude142 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
43cf9b0Claude143
144 expect(calls.length).toBe(1);
145 expect(calls[0]!.url).toBe(
5e67f6bccanty labs146 "https://vapron.ai/api/hooks/gluecron/push"
43cf9b0Claude147 );
5e67f6bccanty labs148 // The pre-move path 404s on Vapron now — pinned so a revert fails loudly.
149 expect(calls[0]!.url).not.toContain("/api/webhooks/gluecron-push");
43cf9b0Claude150 expect(calls[0]!.init.method).toBe("POST");
151 });
152
5164fabClaude153 it("posts a GitHub-shaped push payload (event, repository, ref, before/after, pusher, commits, sent_at, source)", async () => {
154 const after = "b".repeat(40);
155 const before = "c".repeat(40);
156 const { calls, fn } = captureFetch();
43cf9b0Claude157
9ecf5a4Claude158 await triggerVapronDeploy(
ba93444Claude159 makeArgs({
160 owner: "acme",
161 repo: "api",
162 after,
163 before,
164 ref: "refs/heads/Main",
165 branch: "Main",
166 }),
167 { fetchImpl: fn, sleep: noSleep }
43cf9b0Claude168 );
169
ba93444Claude170 const body = JSON.parse(String(calls[0]!.init.body));
171 expect(body.event).toBe("push");
172 expect(body.repository).toEqual({ full_name: "acme/api" });
173 expect(body.ref).toBe("refs/heads/Main");
174 expect(body.after).toBe(after);
175 expect(body.before).toBe(before);
176 expect(body.pusher).toBeDefined();
177 expect(typeof body.pusher.name).toBe("string");
178 expect(typeof body.pusher.email).toBe("string");
179 expect(Array.isArray(body.commits)).toBe(true);
180 expect(typeof body.sent_at).toBe("string");
181 expect(new Date(body.sent_at).toString()).not.toBe("Invalid Date");
182 expect(body.source).toBe("gluecron");
183 });
184
185 it("signs the body with HMAC-SHA256 in X-Gluecron-Signature when secret is set", async () => {
186 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
187 const { calls, fn } = captureFetch();
188
9ecf5a4Claude189 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
ba93444Claude190
43cf9b0Claude191 const headers = calls[0]!.init.headers as Record<string, string>;
ba93444Claude192 const sentBody = String(calls[0]!.init.body);
193 const expected =
194 "sha256=" +
195 createHmac("sha256", "shared-vultr-secret")
196 .update(sentBody)
197 .digest("hex");
198 expect(headers["X-Gluecron-Signature"]).toBe(expected);
36ec667ccantynz-alt199 // Also sent as X-Gluecron-Signature-256 (same value) so the platform
200 // deploy-agent (/__deploy_webhook), which reads the -256 header, accepts
201 // the exact same request as the tenant receiver.
202 expect(headers["X-Gluecron-Signature-256"]).toBe(expected);
43cf9b0Claude203 expect(headers["Content-Type"]).toBe("application/json");
204 });
205
36ec667ccantynz-alt206 it("omits both signature headers when no secret is configured", async () => {
ba93444Claude207 const { calls, fn } = captureFetch();
43cf9b0Claude208
9ecf5a4Claude209 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
43cf9b0Claude210
211 const headers = calls[0]!.init.headers as Record<string, string>;
ba93444Claude212 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
36ec667ccantynz-alt213 expect(headers["X-Gluecron-Signature-256"]).toBeUndefined();
43cf9b0Claude214 });
215
e61e6cdccanty labs216 it("sends Authorization: Bearer <VAPRON_API_KEY> when the tenant key is set", async () => {
217 process.env.VAPRON_API_KEY = "vap_tenant_key_123";
218 try {
219 const { calls, fn } = captureFetch();
220
221 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
222
223 const headers = calls[0]!.init.headers as Record<string, string>;
224 expect(headers["Authorization"]).toBe("Bearer vap_tenant_key_123");
225 } finally {
226 delete process.env.VAPRON_API_KEY;
227 }
228 });
229
230 it("omits Authorization when no VAPRON_API_KEY is configured", async () => {
231 delete process.env.VAPRON_API_KEY;
232 const { calls, fn } = captureFetch();
233
234 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
235
236 const headers = calls[0]!.init.headers as Record<string, string>;
237 expect(headers["Authorization"]).toBeUndefined();
238 });
239
ba93444Claude240 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
241 const { calls, fn } = captureFetch();
242
9ecf5a4Claude243 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
ba93444Claude244
245 const headers = calls[0]!.init.headers as Record<string, string>;
246 expect(headers["X-Gluecron-Event"]).toBe("push");
247 expect(headers["X-Gluecron-Delivery"]).toBeDefined();
248 expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0);
249 });
250
251 it("ref carries the actual case of the branch (Main, not main)", async () => {
252 const { calls, fn } = captureFetch();
43cf9b0Claude253
9ecf5a4Claude254 await triggerVapronDeploy(
ba93444Claude255 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
256 { fetchImpl: fn, sleep: noSleep }
43cf9b0Claude257 );
258
259 const body = JSON.parse(String(calls[0]!.init.body));
ba93444Claude260 expect(body.ref).toBe("refs/heads/Main");
261 expect(body.ref).not.toBe("refs/heads/main");
43cf9b0Claude262 });
263
ba93444Claude264 it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => {
265 const responses = [
266 new Response("", { status: 502 }),
267 new Response("", { status: 503 }),
268 new Response("", { status: 200 }),
269 ];
270 const { calls, fn } = captureFetch((i) => responses[i]!);
271 const sleeps: number[] = [];
43cf9b0Claude272
9ecf5a4Claude273 await triggerVapronDeploy(makeArgs(), {
ba93444Claude274 fetchImpl: fn,
275 sleep: async (ms) => { sleeps.push(ms); },
276 retryDelaysMs: [10, 20, 30, 40, 50],
277 });
278
279 expect(calls.length).toBe(3);
280 // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd.
281 expect(sleeps).toEqual([10, 20]);
282 });
283
284 it("gives up after the configured number of attempts on persistent 5xx", async () => {
285 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
286 const sleeps: number[] = [];
287
9ecf5a4Claude288 await triggerVapronDeploy(makeArgs(), {
ba93444Claude289 fetchImpl: fn,
290 sleep: async (ms) => { sleeps.push(ms); },
291 retryDelaysMs: [1, 2, 3, 4, 5],
292 });
293
294 // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once).
295 expect(calls.length).toBe(6);
296 expect(sleeps).toEqual([1, 2, 3, 4, 5]);
297 });
298
299 it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => {
300 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
301 const sleeps: number[] = [];
302
9ecf5a4Claude303 await triggerVapronDeploy(makeArgs(), {
ba93444Claude304 fetchImpl: fn,
305 sleep: async (ms) => { sleeps.push(ms); },
306 retryDelaysMs: [1, 2, 3, 4, 5],
307 });
43cf9b0Claude308
309 expect(calls.length).toBe(1);
ba93444Claude310 expect(sleeps).toEqual([]);
43cf9b0Claude311 });
312
ba93444Claude313 it("does retry 408 (request timeout) and 429 (rate limit)", async () => {
314 const responses = [
315 new Response("", { status: 429 }),
316 new Response("", { status: 408 }),
317 new Response("", { status: 200 }),
318 ];
319 const { calls, fn } = captureFetch((i) => responses[i]!);
320
9ecf5a4Claude321 await triggerVapronDeploy(makeArgs(), {
ba93444Claude322 fetchImpl: fn,
323 sleep: noSleep,
324 retryDelaysMs: [1, 2, 3, 4, 5],
325 });
326
327 expect(calls.length).toBe(3);
328 });
329
330 it("retries on network errors (fetch throws)", async () => {
331 let callCount = 0;
332 const fn = (async () => {
333 callCount++;
334 if (callCount < 3) throw new Error("ECONNREFUSED");
335 return new Response("", { status: 200 });
336 }) as unknown as typeof fetch;
337
9ecf5a4Claude338 await triggerVapronDeploy(makeArgs(), {
ba93444Claude339 fetchImpl: fn,
340 sleep: noSleep,
341 retryDelaysMs: [1, 2, 3, 4, 5],
342 });
343
344 expect(callCount).toBe(3);
43cf9b0Claude345 });
346
ba93444Claude347 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
348 const { fn } = captureFetch(() => new Response("", { status: 401 }));
43cf9b0Claude349 await expect(
9ecf5a4Claude350 triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
43cf9b0Claude351 ).resolves.toBeUndefined();
ba93444Claude352 });
353
354 it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => {
355 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
43cf9b0Claude356 });
357});
9ecf5a4Claude358
359describe("vapron config — legacy CRONTECH_* env fallback", () => {
360 const KEYS = [
361 "VAPRON_DEPLOY_URL", "CRONTECH_DEPLOY_URL",
362 "VAPRON_REPO", "CRONTECH_REPO",
363 "VAPRON_HMAC_SECRET", "CRONTECH_HMAC_SECRET", "GLUECRON_WEBHOOK_SECRET",
364 ] as const;
365 const saved: Record<string, string | undefined> = {};
366 beforeEach(() => {
367 for (const k of KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
368 });
369 afterEach(() => {
370 for (const k of KEYS) {
371 if (saved[k] === undefined) delete process.env[k];
372 else process.env[k] = saved[k]!;
373 }
374 });
375
376 it("defaults to the vapron.ai webhook URL and ccantynz-alt/vapron repo", () => {
5e67f6bccanty labs377 expect(config.vapronDeployUrl).toBe("https://vapron.ai/api/hooks/gluecron/push");
9ecf5a4Claude378 expect(config.vapronRepo).toBe("ccantynz-alt/vapron");
379 });
380
381 it("VAPRON_* wins over legacy CRONTECH_*", () => {
382 process.env.VAPRON_DEPLOY_URL = "https://vapron.ai/hook-a";
383 process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b";
384 process.env.VAPRON_REPO = "o/new";
385 process.env.CRONTECH_REPO = "o/old";
386 process.env.VAPRON_HMAC_SECRET = "new-secret";
387 process.env.CRONTECH_HMAC_SECRET = "old-secret";
388 expect(config.vapronDeployUrl).toBe("https://vapron.ai/hook-a");
389 expect(config.vapronRepo).toBe("o/new");
390 expect(config.vapronHmacSecret).toBe("new-secret");
391 });
392
393 it("legacy CRONTECH_* still works when VAPRON_* is unset", () => {
394 process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b";
395 process.env.CRONTECH_REPO = "o/old";
396 process.env.CRONTECH_HMAC_SECRET = "old-secret";
397 expect(config.vapronDeployUrl).toBe("https://crontech.ai/hook-b");
398 expect(config.vapronRepo).toBe("o/old");
399 expect(config.vapronHmacSecret).toBe("old-secret");
400 });
401
402 it("HMAC secret falls back to GLUECRON_WEBHOOK_SECRET last", () => {
403 process.env.GLUECRON_WEBHOOK_SECRET = "oldest-secret";
404 expect(config.vapronHmacSecret).toBe("oldest-secret");
405 });
406});