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

platform-deploys.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.

platform-deploys.test.tsBlame442 lines · 1 contributor
f764c07Claude1/**
2 * Block N3 — Platform deploy timeline tests.
3 *
4 * Exercises:
5 * - POST /api/events/deploy/started (bearer auth + insert + SSE publish)
6 * - POST /api/events/deploy/finished (update + SSE publish)
7 * - Idempotency on run_id
8 * - GET /admin/deploys + GET /admin/deploys/latest.json gating
9 * - relativeTime / shortSha / formatDuration edge formats
10 *
11 * The HTTP tests hit `src/routes/events.ts` directly via its default Hono
12 * sub-app — no main-app mount needed. SSE publication is observed by
13 * subscribing to the broadcaster directly (deterministic in-process).
14 *
15 * DB-backed assertions degrade gracefully: when DATABASE_URL is unset the
16 * insert path returns 500 and we assert {200, 500}-shape contracts. With
17 * a real DB they go strict.
18 *
19 * NO mock.module() calls in this file — we deliberately avoid the
20 * spread-from-real mock pattern's global-bleed footgun. The page-render
21 * tests exercise the helpers directly + app.request() against the gated
22 * routes to confirm 401/403/302 redirects without touching DB rows.
23 */
24
25import {
26 afterAll,
27 afterEach,
28 beforeAll,
29 beforeEach,
30 describe,
31 expect,
32 it,
33} from "bun:test";
34import events, { __test as eventsTest } from "../routes/events";
35import { __test as pageTest } from "../routes/admin-deploys-page";
36import { subscribe, topicSubscriberCount, type SSEEvent } from "../lib/sse";
37import app from "../app";
38
39const TOPIC = "platform:deploys";
40
41const VALID_SHA = "a".repeat(40);
42const RUN_ID_A = "n3-test-run-aaaa-0001";
43const RUN_ID_B = "n3-test-run-bbbb-0002";
44const RUN_ID_C = "n3-test-run-cccc-0003";
45const RUN_ID_D = "n3-test-run-dddd-0004";
46
47const origToken = process.env.DEPLOY_EVENT_TOKEN;
48const HAS_DB = Boolean(process.env.DATABASE_URL);
49
50async function postStarted(
51 body: unknown,
52 headers: Record<string, string> = {}
53): Promise<Response> {
54 return events.request("/deploy/started", {
55 method: "POST",
56 headers: {
57 "Content-Type": "application/json",
58 ...headers,
59 },
60 body: typeof body === "string" ? body : JSON.stringify(body),
61 });
62}
63
64async function postFinished(
65 body: unknown,
66 headers: Record<string, string> = {}
67): Promise<Response> {
68 return events.request("/deploy/finished", {
69 method: "POST",
70 headers: {
71 "Content-Type": "application/json",
72 ...headers,
73 },
74 body: typeof body === "string" ? body : JSON.stringify(body),
75 });
76}
77
78beforeAll(() => {
79 process.env.DEPLOY_EVENT_TOKEN = "n3-bearer-fixture";
80});
81
82afterAll(() => {
83 if (origToken === undefined) delete process.env.DEPLOY_EVENT_TOKEN;
84 else process.env.DEPLOY_EVENT_TOKEN = origToken;
85});
86
87// ---------------------------------------------------------------------------
88// /api/events/deploy/started — auth
89// ---------------------------------------------------------------------------
90
91describe("POST /api/events/deploy/started — bearer auth", () => {
92 it("rejects with 401 when Authorization header is missing", async () => {
93 const res = await postStarted({
94 sha: VALID_SHA,
95 run_id: RUN_ID_A,
96 source: "hetzner-deploy",
97 });
98 expect(res.status).toBe(401);
99 });
100
101 it("rejects with 401 when bearer token is wrong", async () => {
102 const res = await postStarted(
103 { sha: VALID_SHA, run_id: RUN_ID_A, source: "hetzner-deploy" },
104 { authorization: "Bearer not-the-real-token" }
105 );
106 expect(res.status).toBe(401);
107 });
108
109 it("rejects with 401 when DEPLOY_EVENT_TOKEN is unset (refuse-by-default)", async () => {
110 const saved = process.env.DEPLOY_EVENT_TOKEN;
111 delete process.env.DEPLOY_EVENT_TOKEN;
112 try {
113 const res = await postStarted(
114 { sha: VALID_SHA, run_id: RUN_ID_A, source: "hetzner-deploy" },
115 { authorization: "Bearer anything" }
116 );
117 expect(res.status).toBe(401);
118 const body = await res.json();
119 expect(String(body.error).toLowerCase()).toContain("not configured");
120 } finally {
121 if (saved !== undefined) process.env.DEPLOY_EVENT_TOKEN = saved;
122 }
123 });
124});
125
126// ---------------------------------------------------------------------------
127// /api/events/deploy/started — payload validation
128// ---------------------------------------------------------------------------
129
130describe("POST /api/events/deploy/started — payload validation", () => {
131 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
132
133 it("rejects malformed JSON with 400", async () => {
134 const res = await postStarted("{not-json", authHeader);
135 expect(res.status).toBe(400);
136 });
137
138 it("rejects missing run_id with 400", async () => {
139 const res = await postStarted(
140 { sha: VALID_SHA, source: "hetzner-deploy" },
141 authHeader
142 );
143 expect(res.status).toBe(400);
144 });
145
146 it("rejects non-hex sha with 400", async () => {
147 const res = await postStarted(
148 { sha: "xyz!", run_id: RUN_ID_A, source: "hetzner-deploy" },
149 authHeader
150 );
151 expect(res.status).toBe(400);
152 });
153
154 it("accepts a 7-char short sha (CI sometimes ships abbrev)", () => {
155 const result = eventsTest.validateStarted({
156 sha: "a1b2c3d",
157 run_id: RUN_ID_A,
158 source: "hetzner-deploy",
159 });
160 expect(result.ok).toBe(true);
161 });
162
163 it("rejects an overlong run_id with 400", async () => {
164 const res = await postStarted(
165 {
166 sha: VALID_SHA,
167 run_id: "x".repeat(129),
168 source: "hetzner-deploy",
169 },
170 authHeader
171 );
172 expect(res.status).toBe(400);
173 });
174});
175
176// ---------------------------------------------------------------------------
177// /api/events/deploy/finished — payload validation
178// ---------------------------------------------------------------------------
179
180describe("POST /api/events/deploy/finished — payload validation", () => {
181 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
182
183 it("rejects unknown status with 400", async () => {
184 const res = await postFinished(
185 { run_id: RUN_ID_A, status: "weird" },
186 authHeader
187 );
188 expect(res.status).toBe(400);
189 });
190
191 it("rejects negative duration_ms with 400", async () => {
192 const res = await postFinished(
193 { run_id: RUN_ID_A, status: "succeeded", duration_ms: -1 },
194 authHeader
195 );
196 expect(res.status).toBe(400);
197 });
198
199 it("accepts succeeded with no error/duration", () => {
200 const result = eventsTest.validateFinished({
201 run_id: RUN_ID_A,
202 status: "succeeded",
203 });
204 expect(result.ok).toBe(true);
205 });
206
207 it("accepts failed with an error string", () => {
208 const result = eventsTest.validateFinished({
209 run_id: RUN_ID_A,
210 status: "failed",
211 duration_ms: 42_000,
212 error: "smoke test returned 502",
213 });
214 expect(result.ok).toBe(true);
215 });
216
217 it("caps very long error strings to 8 KB on the validator", () => {
218 const result = eventsTest.validateFinished({
219 run_id: RUN_ID_A,
220 status: "failed",
221 error: "x".repeat(20_000),
222 });
223 expect(result.ok).toBe(true);
224 if (result.ok) {
225 expect((result.payload.error || "").length).toBeLessThanOrEqual(
226 8 * 1024
227 );
228 }
229 });
230});
231
232// ---------------------------------------------------------------------------
233// SSE publication — verified in-process via lib/sse. No DB round-trip needed
234// to confirm publish() fires (we always publish on success), but we DO need
235// the DB INSERT to succeed for the started handler to reach the publish
236// branch. Skipped when DATABASE_URL is unset.
237// ---------------------------------------------------------------------------
238
239describe("/api/events/deploy/started — DB-aware insert + publish", () => {
240 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
241
242 // Hold every event the broadcaster delivers for `platform:deploys` while
243 // a test is running. Unsubscribe in afterEach so the suite leaves the
244 // broadcaster's subscriber count at zero — sse.test.ts asserts that
245 // contract.
246 let received: SSEEvent[] = [];
247 let unsubscribe: (() => void) | null = null;
248
249 beforeEach(() => {
250 received = [];
251 unsubscribe = subscribe(TOPIC, (e) => received.push(e));
252 });
253
254 afterEach(() => {
255 if (unsubscribe) unsubscribe();
256 unsubscribe = null;
257 expect(topicSubscriberCount(TOPIC)).toBe(0);
258 });
259
260 it("first delivery: inserts a row + publishes deploy.started (DB only)", async () => {
261 const res = await postStarted(
262 { sha: VALID_SHA, run_id: RUN_ID_B, source: "hetzner-deploy" },
263 authHeader
264 );
265 if (HAS_DB) {
266 expect(res.status).toBe(200);
267 const body = await res.json();
268 expect(body.ok).toBe(true);
269 expect(body.duplicate).toBe(false);
270 // publish() is synchronous so by the time await returns the event
271 // has already been pushed to subscribers.
272 expect(received.length).toBe(1);
273 expect(received[0]?.event).toBe("deploy.started");
274 const data = received[0]?.data as any;
275 expect(data.run_id).toBe(RUN_ID_B);
276 expect(data.sha).toBe(VALID_SHA);
277 expect(data.status).toBe("in_progress");
278 } else {
279 expect([200, 500]).toContain(res.status);
280 }
281 });
282
283 it("idempotency: replaying the same run_id returns duplicate:true and does NOT republish", async () => {
284 const payload = {
285 sha: VALID_SHA,
286 run_id: RUN_ID_C,
287 source: "hetzner-deploy",
288 };
289 const first = await postStarted(payload, authHeader);
290 const before = received.length;
291 const second = await postStarted(payload, authHeader);
292 if (HAS_DB) {
293 expect(first.status).toBe(200);
294 expect(second.status).toBe(200);
295 const secondBody = await second.json();
296 expect(secondBody.duplicate).toBe(true);
297 // No new SSE event from the duplicate.
298 expect(received.length).toBe(before);
299 } else {
300 expect([200, 500]).toContain(first.status);
301 expect([200, 500]).toContain(second.status);
302 }
303 });
304});
305
306describe("/api/events/deploy/finished — DB-aware update + publish", () => {
307 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
308
309 let received: SSEEvent[] = [];
310 let unsubscribe: (() => void) | null = null;
311
312 beforeEach(() => {
313 received = [];
314 unsubscribe = subscribe(TOPIC, (e) => received.push(e));
315 });
316
317 afterEach(() => {
318 if (unsubscribe) unsubscribe();
319 unsubscribe = null;
320 expect(topicSubscriberCount(TOPIC)).toBe(0);
321 });
322
323 it("updates the matching row + publishes deploy.finished (DB only)", async () => {
324 // Seed via /started so the update path has a row to flip.
325 await postStarted(
326 { sha: VALID_SHA, run_id: RUN_ID_D, source: "hetzner-deploy" },
327 authHeader
328 );
329 const before = received.length;
330 const res = await postFinished(
331 {
332 run_id: RUN_ID_D,
333 status: "succeeded",
334 duration_ms: 12_000,
335 },
336 authHeader
337 );
338 if (HAS_DB) {
339 expect(res.status).toBe(200);
340 // After /started we get one event; after /finished we should get a
341 // second on top of it.
342 expect(received.length).toBeGreaterThan(before);
343 const finishedEvent = received[received.length - 1];
344 expect(finishedEvent?.event).toBe("deploy.finished");
345 const data = finishedEvent?.data as any;
346 expect(data.run_id).toBe(RUN_ID_D);
347 expect(data.status).toBe("succeeded");
348 } else {
349 expect([200, 500]).toContain(res.status);
350 }
351 });
352});
353
354// ---------------------------------------------------------------------------
355// /admin/deploys gating — hit the real app router to confirm anonymous
356// + non-admin paths are blocked.
357// ---------------------------------------------------------------------------
358
359describe("/admin/deploys gating", () => {
360 it("redirects anonymous users to /login (HTML page)", async () => {
361 const res = await app.request("/admin/deploys", { redirect: "manual" });
362 // Either 302 to /login (gate redirect) or 403 if the gate flips to
363 // forbidden first. Both are non-200 / non-leak.
364 expect([302, 303, 401, 403]).toContain(res.status);
365 });
366
367 it("returns 401 JSON for anonymous on /admin/deploys/latest.json", async () => {
368 const res = await app.request("/admin/deploys/latest.json");
369 expect([401, 403]).toContain(res.status);
370 const body = await res.json();
371 expect(body.ok).toBe(false);
372 });
373
374 it("renders a page (200 + HTML) for the site admin", async () => {
375 // Without spinning up an authed session this case is hard to verify
376 // end-to-end. We assert the route is at least registered + does not
377 // 404 — the gating branches above prove the auth contract.
378 const res = await app.request("/admin/deploys", { redirect: "manual" });
379 expect(res.status).not.toBe(404);
380 });
381});
382
383// ---------------------------------------------------------------------------
384// Status-pill helper formats — pure functions, no I/O.
385// ---------------------------------------------------------------------------
386
387describe("admin-deploys-page helpers — relativeTime", () => {
388 const { relativeTime, shortSha, formatDuration } = pageTest;
389 const ANCHOR = new Date("2026-05-14T12:00:00.000Z");
390 const ago = (ms: number) => new Date(ANCHOR.getTime() - ms);
391
392 it("'just now' for <5s", () => {
393 expect(relativeTime(ago(0), ANCHOR)).toBe("just now");
394 expect(relativeTime(ago(2_000), ANCHOR)).toBe("just now");
395 expect(relativeTime(ago(4_999), ANCHOR)).toBe("just now");
396 });
397
398 it("'Ns ago' for 5-59 seconds", () => {
399 expect(relativeTime(ago(12_000), ANCHOR)).toBe("12s ago");
400 expect(relativeTime(ago(59_000), ANCHOR)).toBe("59s ago");
401 });
402
403 it("'Nm ago' for 1-59 minutes", () => {
404 expect(relativeTime(ago(60_000), ANCHOR)).toBe("1m ago");
405 expect(relativeTime(ago(3 * 60_000), ANCHOR)).toBe("3m ago");
406 expect(relativeTime(ago(59 * 60_000), ANCHOR)).toBe("59m ago");
407 });
408
409 it("'Nh ago' for 1-23 hours", () => {
410 expect(relativeTime(ago(60 * 60_000), ANCHOR)).toBe("1h ago");
411 expect(relativeTime(ago(2 * 60 * 60_000), ANCHOR)).toBe("2h ago");
412 expect(relativeTime(ago(23 * 60 * 60_000), ANCHOR)).toBe("23h ago");
413 });
414
415 it("'Nd ago' for ≥1 day", () => {
416 expect(relativeTime(ago(24 * 60 * 60_000), ANCHOR)).toBe("1d ago");
417 expect(relativeTime(ago(3 * 24 * 60 * 60_000), ANCHOR)).toBe("3d ago");
418 });
419
420 it("clamps clock skew (future Date) to 'just now'", () => {
421 const future = new Date(ANCHOR.getTime() + 5_000);
422 expect(relativeTime(future, ANCHOR)).toBe("just now");
423 });
424
425 it("shortSha returns 7 lowercased hex chars", () => {
426 expect(shortSha("DEADBEEFCAFE1234")).toBe("deadbee");
427 expect(shortSha("abc")).toBe("abc");
428 expect(shortSha("")).toBe("");
429 });
430
431 it("formatDuration handles seconds + minutes + invalid input", () => {
432 expect(formatDuration(0)).toBe("0s");
433 expect(formatDuration(12_000)).toBe("12s");
434 expect(formatDuration(59_000)).toBe("59s");
435 expect(formatDuration(60_000)).toBe("1m");
436 expect(formatDuration(74_000)).toBe("1m 14s");
437 expect(formatDuration(null)).toBe("—");
438 expect(formatDuration(undefined)).toBe("—");
439 expect(formatDuration(-5)).toBe("—");
440 expect(formatDuration(NaN)).toBe("—");
441 });
442});