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.tsBlame276 lines · 1 contributor
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;
26
27function makePayload(
28 overrides: Partial<Record<string, unknown>> = {}
29): Record<string, unknown> {
30 return {
31 event: "deploy.succeeded",
32 eventId: VALID_EVENT_ID_A,
33 repository: "alice/widgets",
34 sha: VALID_SHA,
35 environment: "production",
36 deploymentId: "crontech-dep-123",
37 timestamp: "2025-06-01T12:00:00.000Z",
38 ...overrides,
39 };
40}
41
42async function post(
43 body: unknown,
44 headers: Record<string, string> = {}
45): Promise<Response> {
46 return events.request("/deploy", {
47 method: "POST",
48 headers: {
49 "Content-Type": "application/json",
50 ...headers,
51 },
52 body: typeof body === "string" ? body : JSON.stringify(body),
53 });
54}
55
56beforeAll(() => {
57 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
58});
59
60afterAll(() => {
61 if (origToken === undefined) delete process.env.CRONTECH_EVENT_TOKEN;
62 else process.env.CRONTECH_EVENT_TOKEN = origToken;
63});
64
65// ---------------------------------------------------------------------------
66// Bearer auth
67// ---------------------------------------------------------------------------
68
69describe("events/deploy — bearer auth", () => {
70 it("rejects with 401 when Authorization header is missing", async () => {
71 const res = await post(makePayload());
72 expect(res.status).toBe(401);
73 const body = await res.json();
74 expect(body.ok).toBe(false);
75 expect(String(body.error).toLowerCase()).toContain("bearer");
76 });
77
78 it("rejects with 401 when Bearer token is wrong", async () => {
79 const res = await post(makePayload(), {
80 authorization: "Bearer not-the-real-token",
81 });
82 expect(res.status).toBe(401);
83 const body = await res.json();
84 expect(body.ok).toBe(false);
85 });
86
87 it("rejects with 401 when CRONTECH_EVENT_TOKEN is unset (refuse-by-default)", async () => {
88 const saved = process.env.CRONTECH_EVENT_TOKEN;
89 delete process.env.CRONTECH_EVENT_TOKEN;
90 try {
91 const res = await post(makePayload(), {
92 authorization: "Bearer anything",
93 });
94 expect(res.status).toBe(401);
95 const body = await res.json();
96 expect(String(body.error).toLowerCase()).toContain("not configured");
97 } finally {
98 if (saved !== undefined) process.env.CRONTECH_EVENT_TOKEN = saved;
99 }
100 });
101});
102
103// ---------------------------------------------------------------------------
104// Payload validation
105// ---------------------------------------------------------------------------
106
107describe("events/deploy — payload validation", () => {
108 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
109
110 it("rejects malformed JSON with 400", async () => {
111 const res = await post("{not-json", authHeader);
112 expect(res.status).toBe(400);
113 const body = await res.json();
114 expect(String(body.error).toLowerCase()).toContain("json");
115 });
116
117 it("rejects unknown event type with 400", async () => {
118 const res = await post(makePayload({ event: "deploy.canceled" }), authHeader);
119 expect(res.status).toBe(400);
120 });
121
122 it("rejects non-uuid eventId with 400", async () => {
123 const res = await post(makePayload({ eventId: "not-a-uuid" }), authHeader);
124 expect(res.status).toBe(400);
125 const body = await res.json();
126 expect(String(body.error).toLowerCase()).toContain("eventid");
127 });
128
129 it("rejects invalid sha with 400", async () => {
130 const res = await post(makePayload({ sha: "xyz" }), authHeader);
131 expect(res.status).toBe(400);
132 });
133
134 it("rejects deploy.failed without errorCategory + errorSummary", async () => {
135 const res = await post(
136 makePayload({
137 event: "deploy.failed",
138 eventId: VALID_EVENT_ID_B,
139 }),
140 authHeader
141 );
142 expect(res.status).toBe(400);
143 const body = await res.json();
144 expect(String(body.error).toLowerCase()).toMatch(/errorcategory|errorsummary/);
145 });
146
147 it("rejects errorSummary over 500 chars on deploy.failed", async () => {
148 const res = await post(
149 makePayload({
150 event: "deploy.failed",
151 eventId: VALID_EVENT_ID_B,
152 errorCategory: "build",
153 errorSummary: "x".repeat(501),
154 }),
155 authHeader
156 );
157 expect(res.status).toBe(400);
158 });
159});
160
161// ---------------------------------------------------------------------------
162// Pure validator (no HTTP) — exercises __test.validatePayload.
163// ---------------------------------------------------------------------------
164
165describe("events/deploy — validatePayload helper", () => {
166 it("accepts a well-formed deploy.succeeded payload", () => {
167 const result = __test.validatePayload(makePayload());
168 expect(result.ok).toBe(true);
169 });
170
171 it("accepts a well-formed deploy.failed payload with required error fields", () => {
172 const result = __test.validatePayload(
173 makePayload({
174 event: "deploy.failed",
175 errorCategory: "runtime",
176 errorSummary: "Container OOM-killed after 42s",
177 })
178 );
179 expect(result.ok).toBe(true);
180 });
181
182 it("rejects non-object bodies", () => {
183 expect(__test.validatePayload(null).ok).toBe(false);
184 expect(__test.validatePayload("hello").ok).toBe(false);
185 expect(__test.validatePayload(42).ok).toBe(false);
186 });
187
188 it("rejects unknown errorCategory on deploy.failed", () => {
189 const result = __test.validatePayload(
190 makePayload({
191 event: "deploy.failed",
192 errorCategory: "nuclear",
193 errorSummary: "boom",
194 })
195 );
196 expect(result.ok).toBe(false);
197 });
198});
199
200// ---------------------------------------------------------------------------
201// Side-effect paths — these hit the DB. Without a DATABASE_URL the handler
202// degrades gracefully (idempotency lookup swallows, insert returns 500, etc.).
203// We run a relaxed assertion in no-DB mode and a strict one with DB.
204// ---------------------------------------------------------------------------
205
206const HAS_DB = Boolean(process.env.DATABASE_URL);
207
208describe("events/deploy — idempotency + update (DB-aware)", () => {
209 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
210
211 beforeEach(() => {
212 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
213 });
214
215 afterEach(() => {
216 // no-op — env restored by afterAll
217 });
218
219 it("E3 deploy.succeeded: returns ok + duplicate:false on first delivery (or 500 without DB)", async () => {
220 const res = await post(
221 makePayload({ eventId: VALID_EVENT_ID_A }),
222 authHeader
223 );
224 if (HAS_DB) {
225 expect(res.status).toBe(200);
226 const body = await res.json();
227 expect(body.ok).toBe(true);
228 expect(body.duplicate).toBe(false);
229 } else {
230 // Without a DB the insert into processed_events will throw; handler
231 // returns 500 with {ok:false}. This is the "graceful no-DB" contract.
232 expect([200, 500]).toContain(res.status);
233 }
234 });
235
236 it("replaying the same eventId returns duplicate:true and does not double-side-effect", async () => {
237 const payload = makePayload({ eventId: VALID_EVENT_ID_A });
238 const first = await post(payload, authHeader);
239 const second = await post(payload, authHeader);
240
241 if (HAS_DB) {
242 expect(first.status).toBe(200);
243 expect(second.status).toBe(200);
244 const firstBody = await first.json();
245 const secondBody = await second.json();
246 // Whichever call loses the race is duplicate. At most one is false.
247 const duplicates = [firstBody.duplicate, secondBody.duplicate];
248 expect(duplicates.filter(Boolean).length).toBeGreaterThanOrEqual(1);
249 } else {
250 // Without DB, both attempts fail the insert; we only assert that both
251 // return a JSON body rather than crashing.
252 expect([200, 500]).toContain(first.status);
253 expect([200, 500]).toContain(second.status);
254 }
255 });
256
257 it("E4 deploy.failed is accepted and returns JSON (DB-aware)", async () => {
258 const res = await post(
259 makePayload({
260 event: "deploy.failed",
261 eventId: VALID_EVENT_ID_B,
262 errorCategory: "build",
263 errorSummary: "npm install exited 1",
264 logsUrl: "https://crontech.ai/logs/xyz",
265 }),
266 authHeader
267 );
268 if (HAS_DB) {
269 expect(res.status).toBe(200);
270 const body = await res.json();
271 expect(body.ok).toBe(true);
272 } else {
273 expect([200, 500]).toContain(res.status);
274 }
275 });
276});