CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| 43cf9b0 | 1 | /** |
| 9ecf5a4 | 2 | * BLK-016 — Vapron deploy webhook sender. |
| 43cf9b0 | 3 | * |
| 9ecf5a4 | 4 | * Asserts that `triggerVapronDeploy` (in `src/hooks/post-receive.ts`) |
| ba93444 | 5 | * matches the wire contract documented at the top of that helper, which |
| 9ecf5a4 | 6 | * is the inbound contract for Vapron's |
| ba93444 | 7 | * `apps/api/src/webhooks/gluecron-push.ts` receiver: |
| 43cf9b0 | 8 | * |
| 9ecf5a4 | 9 | * POST https://vapron.ai/api/webhooks/gluecron-push |
| 43cf9b0 | 10 | * Content-Type: application/json |
| ba93444 | 11 | * 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. | |
| 43cf9b0 | 23 | * |
| 24 | * The helper swallows DB errors, so these tests work without a real DB. | |
| 25 | */ | |
| 26 | ||
| 27 | import { afterEach, beforeEach, describe, expect, it } from "bun:test"; | |
| ba93444 | 28 | import { createHmac } from "crypto"; |
| 43cf9b0 | 29 | import { __test } from "../hooks/post-receive"; |
| 9ecf5a4 | 30 | import { config } from "../lib/config"; |
| 43cf9b0 | 31 | |
| 9ecf5a4 | 32 | const { triggerVapronDeploy, signBody } = __test; |
| 43cf9b0 | 33 | |
| 34 | interface CapturedCall { | |
| 35 | url: string; | |
| 36 | init: RequestInit; | |
| 37 | } | |
| 38 | ||
| 39 | const origSecret = process.env.GLUECRON_WEBHOOK_SECRET; | |
| 9ecf5a4 | 40 | const origUrl = process.env.VAPRON_DEPLOY_URL; |
| 41 | const origRepo = process.env.VAPRON_REPO; | |
| ba93444 | 42 | |
| 43 | const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000"; | |
| 44 | const ZERO_SHA = "0000000000000000000000000000000000000000"; | |
| 43cf9b0 | 45 | |
| ba93444 | 46 | function 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", | |
| 9ecf5a4 | 57 | repo: "vapron", |
| ba93444 | 58 | 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 | } | |
| 43cf9b0 | 66 | |
| ba93444 | 67 | function captureFetch( |
| 68 | responder: (callIdx: number) => Response | Promise<Response> = () => | |
| 43cf9b0 | 69 | new Response( |
| ba93444 | 70 | JSON.stringify({ ok: true, deploymentId: "d1" }), |
| 43cf9b0 | 71 | { status: 200, headers: { "Content-Type": "application/json" } } |
| 72 | ) | |
| ba93444 | 73 | ): { calls: CapturedCall[]; fn: typeof fetch } { |
| 43cf9b0 | 74 | const calls: CapturedCall[] = []; |
| ba93444 | 75 | const fn = (async ( |
| 43cf9b0 | 76 | input: RequestInfo | URL, |
| 77 | init: RequestInit = {} | |
| 78 | ): Promise<Response> => { | |
| ba93444 | 79 | const i = calls.length; |
| 43cf9b0 | 80 | calls.push({ url: String(input), init }); |
| ba93444 | 81 | return responder(i); |
| 82 | }) as unknown as typeof fetch; | |
| 83 | return { calls, fn }; | |
| 43cf9b0 | 84 | } |
| 85 | ||
| ba93444 | 86 | const noSleep = async (_ms: number) => {}; |
| 87 | ||
| 88 | describe("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 | }); | |
| 43cf9b0 | 106 | |
| ba93444 | 107 | 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 | }); | |
| 43cf9b0 | 113 | |
| 9ecf5a4 | 114 | describe("hooks/post-receive — triggerVapronDeploy (BLK-016 sender)", () => { |
| 43cf9b0 | 115 | beforeEach(() => { |
| 116 | delete process.env.GLUECRON_WEBHOOK_SECRET; | |
| 9ecf5a4 | 117 | 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 | |
| 43cf9b0 | 121 | delete process.env.CRONTECH_DEPLOY_URL; |
| ba93444 | 122 | delete process.env.CRONTECH_REPO; |
| 9ecf5a4 | 123 | delete process.env.CRONTECH_HMAC_SECRET; |
| 43cf9b0 | 124 | }); |
| 125 | ||
| 126 | afterEach(() => { | |
| 127 | if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET; | |
| 128 | else process.env.GLUECRON_WEBHOOK_SECRET = origSecret; | |
| 9ecf5a4 | 129 | 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; | |
| 43cf9b0 | 133 | }); |
| 134 | ||
| 135 | it("is exported from __test", () => { | |
| 9ecf5a4 | 136 | expect(typeof triggerVapronDeploy).toBe("function"); |
| 43cf9b0 | 137 | }); |
| 138 | ||
| 5e67f6b | 139 | it("POSTs to /api/hooks/gluecron/push (matches Vapron receiver path, verified live 2026-07-14)", async () => { |
| ba93444 | 140 | const { calls, fn } = captureFetch(); |
| 43cf9b0 | 141 | |
| 9ecf5a4 | 142 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); |
| 43cf9b0 | 143 | |
| 144 | expect(calls.length).toBe(1); | |
| 145 | expect(calls[0]!.url).toBe( | |
| 5e67f6b | 146 | "https://vapron.ai/api/hooks/gluecron/push" |
| 43cf9b0 | 147 | ); |
| 5e67f6b | 148 | // 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"); | |
| 43cf9b0 | 150 | expect(calls[0]!.init.method).toBe("POST"); |
| 151 | }); | |
| 152 | ||
| 5164fab | 153 | 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(); | |
| 43cf9b0 | 157 | |
| 9ecf5a4 | 158 | await triggerVapronDeploy( |
| ba93444 | 159 | 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 } | |
| 43cf9b0 | 168 | ); |
| 169 | ||
| ba93444 | 170 | 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 | ||
| 9ecf5a4 | 189 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); |
| ba93444 | 190 | |
| 43cf9b0 | 191 | const headers = calls[0]!.init.headers as Record<string, string>; |
| ba93444 | 192 | 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); | |
| 43cf9b0 | 199 | expect(headers["Content-Type"]).toBe("application/json"); |
| 200 | }); | |
| 201 | ||
| ba93444 | 202 | it("omits X-Gluecron-Signature when no secret is configured", async () => { |
| 203 | const { calls, fn } = captureFetch(); | |
| 43cf9b0 | 204 | |
| 9ecf5a4 | 205 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); |
| 43cf9b0 | 206 | |
| 207 | const headers = calls[0]!.init.headers as Record<string, string>; | |
| ba93444 | 208 | expect(headers["X-Gluecron-Signature"]).toBeUndefined(); |
| 43cf9b0 | 209 | }); |
| 210 | ||
| e61e6cd | 211 | it("sends Authorization: Bearer <VAPRON_API_KEY> when the tenant key is set", async () => { |
| 212 | process.env.VAPRON_API_KEY = "vap_tenant_key_123"; | |
| 213 | try { | |
| 214 | const { calls, fn } = captureFetch(); | |
| 215 | ||
| 216 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); | |
| 217 | ||
| 218 | const headers = calls[0]!.init.headers as Record<string, string>; | |
| 219 | expect(headers["Authorization"]).toBe("Bearer vap_tenant_key_123"); | |
| 220 | } finally { | |
| 221 | delete process.env.VAPRON_API_KEY; | |
| 222 | } | |
| 223 | }); | |
| 224 | ||
| 225 | it("omits Authorization when no VAPRON_API_KEY is configured", async () => { | |
| 226 | delete process.env.VAPRON_API_KEY; | |
| 227 | const { calls, fn } = captureFetch(); | |
| 228 | ||
| 229 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); | |
| 230 | ||
| 231 | const headers = calls[0]!.init.headers as Record<string, string>; | |
| 232 | expect(headers["Authorization"]).toBeUndefined(); | |
| 233 | }); | |
| 234 | ||
| ba93444 | 235 | it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => { |
| 236 | const { calls, fn } = captureFetch(); | |
| 237 | ||
| 9ecf5a4 | 238 | await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }); |
| ba93444 | 239 | |
| 240 | const headers = calls[0]!.init.headers as Record<string, string>; | |
| 241 | expect(headers["X-Gluecron-Event"]).toBe("push"); | |
| 242 | expect(headers["X-Gluecron-Delivery"]).toBeDefined(); | |
| 243 | expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0); | |
| 244 | }); | |
| 245 | ||
| 246 | it("ref carries the actual case of the branch (Main, not main)", async () => { | |
| 247 | const { calls, fn } = captureFetch(); | |
| 43cf9b0 | 248 | |
| 9ecf5a4 | 249 | await triggerVapronDeploy( |
| ba93444 | 250 | makeArgs({ ref: "refs/heads/Main", branch: "Main" }), |
| 251 | { fetchImpl: fn, sleep: noSleep } | |
| 43cf9b0 | 252 | ); |
| 253 | ||
| 254 | const body = JSON.parse(String(calls[0]!.init.body)); | |
| ba93444 | 255 | expect(body.ref).toBe("refs/heads/Main"); |
| 256 | expect(body.ref).not.toBe("refs/heads/main"); | |
| 43cf9b0 | 257 | }); |
| 258 | ||
| ba93444 | 259 | it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => { |
| 260 | const responses = [ | |
| 261 | new Response("", { status: 502 }), | |
| 262 | new Response("", { status: 503 }), | |
| 263 | new Response("", { status: 200 }), | |
| 264 | ]; | |
| 265 | const { calls, fn } = captureFetch((i) => responses[i]!); | |
| 266 | const sleeps: number[] = []; | |
| 43cf9b0 | 267 | |
| 9ecf5a4 | 268 | await triggerVapronDeploy(makeArgs(), { |
| ba93444 | 269 | fetchImpl: fn, |
| 270 | sleep: async (ms) => { sleeps.push(ms); }, | |
| 271 | retryDelaysMs: [10, 20, 30, 40, 50], | |
| 272 | }); | |
| 273 | ||
| 274 | expect(calls.length).toBe(3); | |
| 275 | // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd. | |
| 276 | expect(sleeps).toEqual([10, 20]); | |
| 277 | }); | |
| 278 | ||
| 279 | it("gives up after the configured number of attempts on persistent 5xx", async () => { | |
| 280 | const { calls, fn } = captureFetch(() => new Response("", { status: 500 })); | |
| 281 | const sleeps: number[] = []; | |
| 282 | ||
| 9ecf5a4 | 283 | await triggerVapronDeploy(makeArgs(), { |
| ba93444 | 284 | fetchImpl: fn, |
| 285 | sleep: async (ms) => { sleeps.push(ms); }, | |
| 286 | retryDelaysMs: [1, 2, 3, 4, 5], | |
| 287 | }); | |
| 288 | ||
| 289 | // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once). | |
| 290 | expect(calls.length).toBe(6); | |
| 291 | expect(sleeps).toEqual([1, 2, 3, 4, 5]); | |
| 292 | }); | |
| 293 | ||
| 294 | it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => { | |
| 295 | const { calls, fn } = captureFetch(() => new Response("", { status: 401 })); | |
| 296 | const sleeps: number[] = []; | |
| 297 | ||
| 9ecf5a4 | 298 | await triggerVapronDeploy(makeArgs(), { |
| ba93444 | 299 | fetchImpl: fn, |
| 300 | sleep: async (ms) => { sleeps.push(ms); }, | |
| 301 | retryDelaysMs: [1, 2, 3, 4, 5], | |
| 302 | }); | |
| 43cf9b0 | 303 | |
| 304 | expect(calls.length).toBe(1); | |
| ba93444 | 305 | expect(sleeps).toEqual([]); |
| 43cf9b0 | 306 | }); |
| 307 | ||
| ba93444 | 308 | it("does retry 408 (request timeout) and 429 (rate limit)", async () => { |
| 309 | const responses = [ | |
| 310 | new Response("", { status: 429 }), | |
| 311 | new Response("", { status: 408 }), | |
| 312 | new Response("", { status: 200 }), | |
| 313 | ]; | |
| 314 | const { calls, fn } = captureFetch((i) => responses[i]!); | |
| 315 | ||
| 9ecf5a4 | 316 | await triggerVapronDeploy(makeArgs(), { |
| ba93444 | 317 | fetchImpl: fn, |
| 318 | sleep: noSleep, | |
| 319 | retryDelaysMs: [1, 2, 3, 4, 5], | |
| 320 | }); | |
| 321 | ||
| 322 | expect(calls.length).toBe(3); | |
| 323 | }); | |
| 324 | ||
| 325 | it("retries on network errors (fetch throws)", async () => { | |
| 326 | let callCount = 0; | |
| 327 | const fn = (async () => { | |
| 328 | callCount++; | |
| 329 | if (callCount < 3) throw new Error("ECONNREFUSED"); | |
| 330 | return new Response("", { status: 200 }); | |
| 331 | }) as unknown as typeof fetch; | |
| 332 | ||
| 9ecf5a4 | 333 | await triggerVapronDeploy(makeArgs(), { |
| ba93444 | 334 | fetchImpl: fn, |
| 335 | sleep: noSleep, | |
| 336 | retryDelaysMs: [1, 2, 3, 4, 5], | |
| 337 | }); | |
| 338 | ||
| 339 | expect(callCount).toBe(3); | |
| 43cf9b0 | 340 | }); |
| 341 | ||
| ba93444 | 342 | it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => { |
| 343 | const { fn } = captureFetch(() => new Response("", { status: 401 })); | |
| 43cf9b0 | 344 | await expect( |
| 9ecf5a4 | 345 | triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep }) |
| 43cf9b0 | 346 | ).resolves.toBeUndefined(); |
| ba93444 | 347 | }); |
| 348 | ||
| 349 | it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => { | |
| 350 | expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]); | |
| 43cf9b0 | 351 | }); |
| 352 | }); | |
| 9ecf5a4 | 353 | |
| 354 | describe("vapron config — legacy CRONTECH_* env fallback", () => { | |
| 355 | const KEYS = [ | |
| 356 | "VAPRON_DEPLOY_URL", "CRONTECH_DEPLOY_URL", | |
| 357 | "VAPRON_REPO", "CRONTECH_REPO", | |
| 358 | "VAPRON_HMAC_SECRET", "CRONTECH_HMAC_SECRET", "GLUECRON_WEBHOOK_SECRET", | |
| 359 | ] as const; | |
| 360 | const saved: Record<string, string | undefined> = {}; | |
| 361 | beforeEach(() => { | |
| 362 | for (const k of KEYS) { saved[k] = process.env[k]; delete process.env[k]; } | |
| 363 | }); | |
| 364 | afterEach(() => { | |
| 365 | for (const k of KEYS) { | |
| 366 | if (saved[k] === undefined) delete process.env[k]; | |
| 367 | else process.env[k] = saved[k]!; | |
| 368 | } | |
| 369 | }); | |
| 370 | ||
| 371 | it("defaults to the vapron.ai webhook URL and ccantynz-alt/vapron repo", () => { | |
| 5e67f6b | 372 | expect(config.vapronDeployUrl).toBe("https://vapron.ai/api/hooks/gluecron/push"); |
| 9ecf5a4 | 373 | expect(config.vapronRepo).toBe("ccantynz-alt/vapron"); |
| 374 | }); | |
| 375 | ||
| 376 | it("VAPRON_* wins over legacy CRONTECH_*", () => { | |
| 377 | process.env.VAPRON_DEPLOY_URL = "https://vapron.ai/hook-a"; | |
| 378 | process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b"; | |
| 379 | process.env.VAPRON_REPO = "o/new"; | |
| 380 | process.env.CRONTECH_REPO = "o/old"; | |
| 381 | process.env.VAPRON_HMAC_SECRET = "new-secret"; | |
| 382 | process.env.CRONTECH_HMAC_SECRET = "old-secret"; | |
| 383 | expect(config.vapronDeployUrl).toBe("https://vapron.ai/hook-a"); | |
| 384 | expect(config.vapronRepo).toBe("o/new"); | |
| 385 | expect(config.vapronHmacSecret).toBe("new-secret"); | |
| 386 | }); | |
| 387 | ||
| 388 | it("legacy CRONTECH_* still works when VAPRON_* is unset", () => { | |
| 389 | process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b"; | |
| 390 | process.env.CRONTECH_REPO = "o/old"; | |
| 391 | process.env.CRONTECH_HMAC_SECRET = "old-secret"; | |
| 392 | expect(config.vapronDeployUrl).toBe("https://crontech.ai/hook-b"); | |
| 393 | expect(config.vapronRepo).toBe("o/old"); | |
| 394 | expect(config.vapronHmacSecret).toBe("old-secret"); | |
| 395 | }); | |
| 396 | ||
| 397 | it("HMAC secret falls back to GLUECRON_WEBHOOK_SECRET last", () => { | |
| 398 | process.env.GLUECRON_WEBHOOK_SECRET = "oldest-secret"; | |
| 399 | expect(config.vapronHmacSecret).toBe("oldest-secret"); | |
| 400 | }); | |
| 401 | }); |