Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

deploy-events.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.

deploy-events.test.tsBlame283 lines · 2 contributors
9e1e93aClaude1/**
2 * Signal Bus P1 — inbound deploy-event receiver tests (E3/E4).
3 *
4 * Exercises `src/routes/events.ts` directly via its default Hono sub-app so
5 * the suite is hermetic: no need to mount on the main app (which is locked)
6 * and no live DB required. Tests that assert DB-backed side-effects run only
7 * when `DATABASE_URL` is present; otherwise they assert graceful degradation.
8 */
9
10import {
11 afterAll,
12 afterEach,
13 beforeAll,
14 beforeEach,
15 describe,
16 expect,
17 it,
18} from "bun:test";
19import events, { __test } from "../routes/events";
20
21const VALID_EVENT_ID_A = "11111111-1111-4111-8111-111111111111";
22const VALID_EVENT_ID_B = "22222222-2222-4222-8222-222222222222";
23const VALID_SHA = "a".repeat(40);
24
25const origToken = process.env.CRONTECH_EVENT_TOKEN;
9ecf5a4Claude26const origVapronToken = process.env.VAPRON_EVENT_TOKEN;
9e1e93aClaude27
28function makePayload(
29 overrides: Partial<Record<string, unknown>> = {}
30): Record<string, unknown> {
31 return {
32 event: "deploy.succeeded",
33 eventId: VALID_EVENT_ID_A,
34 repository: "alice/widgets",
35 sha: VALID_SHA,
36 environment: "production",
37 deploymentId: "crontech-dep-123",
38 timestamp: "2025-06-01T12:00:00.000Z",
39 ...overrides,
40 };
41}
42
43async function post(
44 body: unknown,
45 headers: Record<string, string> = {}
46): Promise<Response> {
47 return events.request("/deploy", {
48 method: "POST",
49 headers: {
50 "Content-Type": "application/json",
51 ...headers,
52 },
53 body: typeof body === "string" ? body : JSON.stringify(body),
54 });
55}
56
57beforeAll(() => {
ea52715copilot-swe-agent[bot]58 process.env.CRONTECH_EVENT_TOKEN = "unit-bearer-fixture";
9ecf5a4Claude59 delete process.env.VAPRON_EVENT_TOKEN;
9e1e93aClaude60});
61
62afterAll(() => {
63 if (origToken === undefined) delete process.env.CRONTECH_EVENT_TOKEN;
64 else process.env.CRONTECH_EVENT_TOKEN = origToken;
9ecf5a4Claude65 if (origVapronToken === undefined) delete process.env.VAPRON_EVENT_TOKEN;
66 else process.env.VAPRON_EVENT_TOKEN = origVapronToken;
9e1e93aClaude67});
68
69// ---------------------------------------------------------------------------
70// Bearer auth
71// ---------------------------------------------------------------------------
72
73describe("events/deploy — bearer auth", () => {
74 it("rejects with 401 when Authorization header is missing", async () => {
75 const res = await post(makePayload());
76 expect(res.status).toBe(401);
77 const body = await res.json();
78 expect(body.ok).toBe(false);
79 expect(String(body.error).toLowerCase()).toContain("bearer");
80 });
81
82 it("rejects with 401 when Bearer token is wrong", async () => {
83 const res = await post(makePayload(), {
84 authorization: "Bearer not-the-real-token",
85 });
86 expect(res.status).toBe(401);
87 const body = await res.json();
88 expect(body.ok).toBe(false);
89 });
90
9ecf5a4Claude91 it("rejects with 401 when no event token is set (refuse-by-default)", async () => {
9e1e93aClaude92 const saved = process.env.CRONTECH_EVENT_TOKEN;
9ecf5a4Claude93 const savedV = process.env.VAPRON_EVENT_TOKEN;
9e1e93aClaude94 delete process.env.CRONTECH_EVENT_TOKEN;
9ecf5a4Claude95 delete process.env.VAPRON_EVENT_TOKEN;
9e1e93aClaude96 try {
97 const res = await post(makePayload(), {
98 authorization: "Bearer anything",
99 });
100 expect(res.status).toBe(401);
101 const body = await res.json();
102 expect(String(body.error).toLowerCase()).toContain("not configured");
103 } finally {
104 if (saved !== undefined) process.env.CRONTECH_EVENT_TOKEN = saved;
9ecf5a4Claude105 if (savedV !== undefined) process.env.VAPRON_EVENT_TOKEN = savedV;
9e1e93aClaude106 }
107 });
108});
109
110// ---------------------------------------------------------------------------
111// Payload validation
112// ---------------------------------------------------------------------------
113
114describe("events/deploy — payload validation", () => {
ea52715copilot-swe-agent[bot]115 const authHeader = { authorization: "Bearer unit-bearer-fixture" };
9e1e93aClaude116
117 it("rejects malformed JSON with 400", async () => {
118 const res = await post("{not-json", authHeader);
119 expect(res.status).toBe(400);
120 const body = await res.json();
121 expect(String(body.error).toLowerCase()).toContain("json");
122 });
123
124 it("rejects unknown event type with 400", async () => {
125 const res = await post(makePayload({ event: "deploy.canceled" }), authHeader);
126 expect(res.status).toBe(400);
127 });
128
129 it("rejects non-uuid eventId with 400", async () => {
130 const res = await post(makePayload({ eventId: "not-a-uuid" }), authHeader);
131 expect(res.status).toBe(400);
132 const body = await res.json();
133 expect(String(body.error).toLowerCase()).toContain("eventid");
134 });
135
136 it("rejects invalid sha with 400", async () => {
137 const res = await post(makePayload({ sha: "xyz" }), authHeader);
138 expect(res.status).toBe(400);
139 });
140
141 it("rejects deploy.failed without errorCategory + errorSummary", async () => {
142 const res = await post(
143 makePayload({
144 event: "deploy.failed",
145 eventId: VALID_EVENT_ID_B,
146 }),
147 authHeader
148 );
149 expect(res.status).toBe(400);
150 const body = await res.json();
151 expect(String(body.error).toLowerCase()).toMatch(/errorcategory|errorsummary/);
152 });
153
154 it("rejects errorSummary over 500 chars on deploy.failed", async () => {
155 const res = await post(
156 makePayload({
157 event: "deploy.failed",
158 eventId: VALID_EVENT_ID_B,
159 errorCategory: "build",
160 errorSummary: "x".repeat(501),
161 }),
162 authHeader
163 );
164 expect(res.status).toBe(400);
165 });
166});
167
168// ---------------------------------------------------------------------------
169// Pure validator (no HTTP) — exercises __test.validatePayload.
170// ---------------------------------------------------------------------------
171
172describe("events/deploy — validatePayload helper", () => {
173 it("accepts a well-formed deploy.succeeded payload", () => {
174 const result = __test.validatePayload(makePayload());
175 expect(result.ok).toBe(true);
176 });
177
178 it("accepts a well-formed deploy.failed payload with required error fields", () => {
179 const result = __test.validatePayload(
180 makePayload({
181 event: "deploy.failed",
182 errorCategory: "runtime",
183 errorSummary: "Container OOM-killed after 42s",
184 })
185 );
186 expect(result.ok).toBe(true);
187 });
188
189 it("rejects non-object bodies", () => {
190 expect(__test.validatePayload(null).ok).toBe(false);
191 expect(__test.validatePayload("hello").ok).toBe(false);
192 expect(__test.validatePayload(42).ok).toBe(false);
193 });
194
195 it("rejects unknown errorCategory on deploy.failed", () => {
196 const result = __test.validatePayload(
197 makePayload({
198 event: "deploy.failed",
199 errorCategory: "nuclear",
200 errorSummary: "boom",
201 })
202 );
203 expect(result.ok).toBe(false);
204 });
205});
206
207// ---------------------------------------------------------------------------
208// Side-effect paths — these hit the DB. Without a DATABASE_URL the handler
209// degrades gracefully (idempotency lookup swallows, insert returns 500, etc.).
210// We run a relaxed assertion in no-DB mode and a strict one with DB.
211// ---------------------------------------------------------------------------
212
213const HAS_DB = Boolean(process.env.DATABASE_URL);
214
215describe("events/deploy — idempotency + update (DB-aware)", () => {
ea52715copilot-swe-agent[bot]216 const authHeader = { authorization: "Bearer unit-bearer-fixture" };
9e1e93aClaude217
218 beforeEach(() => {
ea52715copilot-swe-agent[bot]219 process.env.CRONTECH_EVENT_TOKEN = "unit-bearer-fixture";
9e1e93aClaude220 });
221
222 afterEach(() => {
223 // no-op — env restored by afterAll
224 });
225
226 it("E3 deploy.succeeded: returns ok + duplicate:false on first delivery (or 500 without DB)", async () => {
227 const res = await post(
228 makePayload({ eventId: VALID_EVENT_ID_A }),
229 authHeader
230 );
231 if (HAS_DB) {
232 expect(res.status).toBe(200);
233 const body = await res.json();
234 expect(body.ok).toBe(true);
235 expect(body.duplicate).toBe(false);
236 } else {
237 // Without a DB the insert into processed_events will throw; handler
238 // returns 500 with {ok:false}. This is the "graceful no-DB" contract.
239 expect([200, 500]).toContain(res.status);
240 }
241 });
242
243 it("replaying the same eventId returns duplicate:true and does not double-side-effect", async () => {
244 const payload = makePayload({ eventId: VALID_EVENT_ID_A });
245 const first = await post(payload, authHeader);
246 const second = await post(payload, authHeader);
247
248 if (HAS_DB) {
249 expect(first.status).toBe(200);
250 expect(second.status).toBe(200);
251 const firstBody = await first.json();
252 const secondBody = await second.json();
253 // Whichever call loses the race is duplicate. At most one is false.
254 const duplicates = [firstBody.duplicate, secondBody.duplicate];
255 expect(duplicates.filter(Boolean).length).toBeGreaterThanOrEqual(1);
256 } else {
257 // Without DB, both attempts fail the insert; we only assert that both
258 // return a JSON body rather than crashing.
259 expect([200, 500]).toContain(first.status);
260 expect([200, 500]).toContain(second.status);
261 }
262 });
263
264 it("E4 deploy.failed is accepted and returns JSON (DB-aware)", async () => {
265 const res = await post(
266 makePayload({
267 event: "deploy.failed",
268 eventId: VALID_EVENT_ID_B,
269 errorCategory: "build",
270 errorSummary: "npm install exited 1",
271 logsUrl: "https://crontech.ai/logs/xyz",
272 }),
273 authHeader
274 );
275 if (HAS_DB) {
276 expect(res.status).toBe(200);
277 const body = await res.json();
278 expect(body.ok).toBe(true);
279 } else {
280 expect([200, 500]).toContain(res.status);
281 }
282 });
283});