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

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

cli-deploy.test.tsBlame567 lines · 1 contributor
f764c07Claude1/**
2 * Block N4 — `gluecron deploy` CLI + /admin/deploys/trigger route tests.
3 *
4 * The CLI tests drive `triggerWorkflowDispatch`, `watchDeploy`, and the
5 * `handleDeployCmd` glue with an injected `fetchImpl` — no network, no real
6 * GitHub. They cover:
7 * - happy path: 204 dispatch → list-runs picks up the new run → exit 0
8 * - 401 / 422 friendly errors
9 * - --no-watch skips the polling loop entirely
10 *
11 * The /admin/deploys/trigger route test uses Bun's `mock.module` to stub
12 * `../db` so the `softAuth → sessionCache` path can mint a fake admin user
13 * without a real Neon connection. The K1-style spread-from-real pattern is
14 * followed and the mock is restored in afterAll so this file never pollutes
15 * the rest of the suite.
16 */
17
18import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
19
20import {
21 triggerWorkflowDispatch,
22 watchDeploy,
23 handleDeployCmd,
24 type FetchLike,
25} from "../../cli/gluecron";
26
27// ---------- helpers ---------------------------------------------------------
28
29function jsonRes(status: number, body: any) {
30 return {
31 status,
32 ok: status >= 200 && status < 300,
33 text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
34 };
35}
36
37function noContent() {
38 return {
39 status: 204,
40 ok: true,
41 text: async () => "",
42 };
43}
44
45function capture() {
46 const lines: string[] = [];
47 return {
48 out: (s: string) => lines.push(s),
49 text: () => lines.join("\n"),
50 lines,
51 };
52}
53
54// =============================================================================
55// CLI — triggerWorkflowDispatch
56// =============================================================================
57
58describe("cli/deploy — triggerWorkflowDispatch", () => {
59 it("POSTs to the right dispatch URL and resolves the latest run", async () => {
60 const calls: Array<{ url: string; method: string; body?: string }> = [];
61 const fakeRun = {
62 id: 99887766,
63 url: "https://api.github.com/repos/foo/bar/actions/runs/99887766",
64 html_url: "https://github.com/foo/bar/actions/runs/99887766",
65 created_at: new Date(Date.now()).toISOString(),
66 };
67 const fetchImpl: FetchLike = async (url, init) => {
68 calls.push({ url, method: init?.method || "GET", body: init?.body });
69 if (init?.method === "POST" && url.includes("/dispatches")) {
70 return noContent();
71 }
72 if (url.includes("/runs?")) {
73 return jsonRes(200, { workflow_runs: [fakeRun] });
74 }
75 throw new Error("unexpected fetch: " + url);
76 };
77
78 const result = await triggerWorkflowDispatch(
79 {
80 repo: "foo/bar",
81 workflow: "hetzner-deploy.yml",
82 ref: "main",
83 githubToken: "ghp_test",
84 },
85 { fetchImpl, now: () => Date.now() }
86 );
87
88 expect(result.runId).toBe(99887766);
89 expect(result.htmlUrl).toContain("/actions/runs/99887766");
90
91 // First call is POST .../workflows/hetzner-deploy.yml/dispatches
92 expect(calls[0].method).toBe("POST");
93 expect(calls[0].url).toBe(
94 "https://api.github.com/repos/foo/bar/actions/workflows/hetzner-deploy.yml/dispatches"
95 );
96 expect(calls[0].body).toBe(JSON.stringify({ ref: "main" }));
97 // Second call is the runs query
98 expect(calls[1].url).toContain(
99 "/repos/foo/bar/actions/workflows/hetzner-deploy.yml/runs"
100 );
101 expect(calls[1].url).toContain("event=workflow_dispatch");
102 expect(calls[1].url).toContain("branch=main");
103 });
104
105 it("maps 401 to a friendly error", async () => {
106 const fetchImpl: FetchLike = async () =>
107 jsonRes(401, { message: "Bad credentials" });
108 await expect(
109 triggerWorkflowDispatch(
110 {
111 repo: "foo/bar",
112 workflow: "hetzner-deploy.yml",
113 ref: "main",
114 githubToken: "ghp_bad",
115 },
116 { fetchImpl }
117 )
118 ).rejects.toThrow(/GitHub auth failed \(401\)/);
119 });
120
121 it("maps 422 (bad ref) to a friendly error", async () => {
122 const fetchImpl: FetchLike = async () =>
123 jsonRes(422, { message: "No ref found for: nope" });
124 await expect(
125 triggerWorkflowDispatch(
126 {
127 repo: "foo/bar",
128 workflow: "hetzner-deploy.yml",
129 ref: "nope",
130 githubToken: "ghp_test",
131 },
132 { fetchImpl }
133 )
134 ).rejects.toThrow(/422.*No ref found/);
135 });
136
137 it("rejects when no GitHub token is provided", async () => {
138 await expect(
139 triggerWorkflowDispatch(
140 { repo: "foo/bar", workflow: "x.yml", ref: "main", githubToken: "" },
141 { fetchImpl: async () => noContent() }
142 )
143 ).rejects.toThrow(/GLUECRON_GITHUB_TOKEN/);
144 });
145
146 it("rejects when --repo is not owner/name", async () => {
147 await expect(
148 triggerWorkflowDispatch(
149 { repo: "no-slash", workflow: "x.yml", ref: "main", githubToken: "x" },
150 { fetchImpl: async () => noContent() }
151 )
152 ).rejects.toThrow(/owner\/name/);
153 });
154});
155
156// =============================================================================
157// CLI — handleDeployCmd glue
158// =============================================================================
159
160describe("cli/deploy — handleDeployCmd", () => {
161 it("--no-watch exits 0 immediately after dispatch (no polling)", async () => {
162 let pollCalls = 0;
163 const fakeRun = {
164 id: 1,
165 url: "u",
166 html_url: "https://github.com/foo/bar/actions/runs/1",
167 created_at: new Date().toISOString(),
168 };
169 const fetchImpl: FetchLike = async (url, init) => {
170 if (init?.method === "POST" && url.includes("/dispatches")) return noContent();
171 if (url.includes("/runs?")) return jsonRes(200, { workflow_runs: [fakeRun] });
172 if (url.includes("/jobs")) {
173 pollCalls++;
174 return jsonRes(200, { jobs: [] });
175 }
176 throw new Error("unexpected: " + url);
177 };
178 const { out, text } = capture();
179 const code = await handleDeployCmd(
180 { host: "x" } as any,
181 [
182 "--repo",
183 "foo/bar",
184 "--gh-token",
185 "ghp_x",
186 "--no-watch",
187 ],
188 out,
189 { fetchImpl }
190 );
191 expect(code).toBe(0);
192 expect(pollCalls).toBe(0);
193 expect(text()).toContain("Triggering hetzner-deploy.yml on foo/bar@main");
194 expect(text()).toContain("Workflow run dispatched");
195 expect(text()).not.toContain("Watching deploy status");
196 });
197
198 it("missing token prints a clear instructional error", async () => {
199 const prevEnv = process.env.GLUECRON_GITHUB_TOKEN;
200 delete process.env.GLUECRON_GITHUB_TOKEN;
201 try {
202 const { out, text } = capture();
203 const code = await handleDeployCmd(
204 { host: "x" } as any,
205 ["--repo", "foo/bar"],
206 out,
207 { fetchImpl: async () => noContent() }
208 );
209 expect(code).toBe(1);
210 expect(text()).toMatch(/GLUECRON_GITHUB_TOKEN/);
211 expect(text()).toMatch(/config set github-token/);
212 } finally {
213 if (prevEnv !== undefined) process.env.GLUECRON_GITHUB_TOKEN = prevEnv;
214 }
215 });
216});
217
218// =============================================================================
219// CLI — watchDeploy poll loop
220// =============================================================================
221
222describe("cli/deploy — watchDeploy", () => {
223 it("logs step transitions and returns ok on success", async () => {
224 const t0 = 1_700_000_000_000;
225 const transcripts = [
226 // poll 1: setup in progress
227 {
228 jobs: [
229 {
230 status: "in_progress",
231 conclusion: null,
232 steps: [
233 {
234 name: "Setup",
235 status: "in_progress",
236 conclusion: null,
237 started_at: new Date(t0 + 5000).toISOString(),
238 completed_at: null,
239 },
240 ],
241 },
242 ],
243 },
244 // poll 2: setup done, deploy in progress
245 {
246 jobs: [
247 {
248 status: "in_progress",
249 conclusion: null,
250 steps: [
251 {
252 name: "Setup",
253 status: "completed",
254 conclusion: "success",
255 started_at: new Date(t0 + 5000).toISOString(),
256 completed_at: new Date(t0 + 18000).toISOString(),
257 },
258 {
259 name: "Deploy",
260 status: "in_progress",
261 conclusion: null,
262 started_at: new Date(t0 + 18000).toISOString(),
263 completed_at: null,
264 },
265 ],
266 },
267 ],
268 },
269 // poll 3: everything done, success
270 {
271 jobs: [
272 {
273 status: "completed",
274 conclusion: "success",
275 steps: [
276 {
277 name: "Setup",
278 status: "completed",
279 conclusion: "success",
280 started_at: new Date(t0 + 5000).toISOString(),
281 completed_at: new Date(t0 + 18000).toISOString(),
282 },
283 {
284 name: "Deploy",
285 status: "completed",
286 conclusion: "success",
287 started_at: new Date(t0 + 18000).toISOString(),
288 completed_at: new Date(t0 + 42000).toISOString(),
289 },
290 ],
291 },
292 ],
293 },
294 ];
295 let pollIdx = 0;
296 const fetchImpl: FetchLike = async () =>
297 jsonRes(200, transcripts[Math.min(pollIdx++, transcripts.length - 1)]);
298
299 let nowMs = t0;
300 const { out, lines } = capture();
301 const res = await watchDeploy(
302 { repo: "foo/bar", runId: 1, githubToken: "x", startedAt: t0 },
303 out,
304 {
305 fetchImpl,
306 pollMs: 0,
307 maxPolls: 10,
308 sleep: async () => {
309 nowMs += 13_000;
310 },
311 now: () => nowMs,
312 }
313 );
314 expect(res.ok).toBe(true);
315 expect(res.conclusion).toBe("success");
316 expect(lines.some((l) => l.includes("Setup (in progress)"))).toBe(true);
317 expect(lines.some((l) => l.includes("Setup (completed in 13s)"))).toBe(true);
318 expect(lines.some((l) => l.includes("Deploy (in progress)"))).toBe(true);
319 expect(lines.some((l) => l.includes("Deploy (completed in 24s)"))).toBe(true);
320 });
321
322 it("returns ok:false when the run concludes with failure", async () => {
323 const fetchImpl: FetchLike = async () =>
324 jsonRes(200, {
325 jobs: [
326 {
327 status: "completed",
328 conclusion: "failure",
329 steps: [
330 {
331 name: "Smoke test",
332 status: "completed",
333 conclusion: "failure",
334 started_at: new Date().toISOString(),
335 completed_at: new Date().toISOString(),
336 },
337 ],
338 },
339 ],
340 });
341 const { out } = capture();
342 const res = await watchDeploy(
343 { repo: "foo/bar", runId: 1, githubToken: "x", startedAt: Date.now() },
344 out,
345 { fetchImpl, pollMs: 0, maxPolls: 1, sleep: async () => {} }
346 );
347 expect(res.ok).toBe(false);
348 expect(res.conclusion).toBe("failure");
349 });
350});
351
352// =============================================================================
353// /admin/deploys/trigger route
354// =============================================================================
355//
356// We DI the github fetcher + GITHUB_TOKEN env via the test-only hooks on the
357// route module so we never hit api.github.com. The session/auth path goes
358// through softAuth + sessionCache, which we pre-warm with a fake user.
359
360const _real_db = await import("../db");
361const _schema = await import("../db/schema");
362
363// Per-test row hooks (matching the layout-user-prop.test.ts pattern).
364let _nextSessionRow: any = null;
365let _nextUserRow: any = null;
366let _nextAdminRow: any = null;
367let _lastSelectFrom: any = null;
368
369const tableName = (t: any): string => {
370 // Identify by object identity against the real drizzle table objects.
371 // Using `"propName" in table` (the previous approach) is unreliable
372 // because drizzle's pgTable proxy doesn't always expose column names
373 // via `has`. Identity comparison is rock-solid.
374 if (t === _schema.sessions) return "sessions";
375 if (t === _schema.users) return "users";
376 if (t === _schema.siteAdmins) return "site_admins";
377 return "?";
378};
379
380const _selectChain: any = {
381 from: (t: any) => {
382 _lastSelectFrom = t;
383 return _selectChain;
384 },
385 innerJoin: () => _selectChain,
386 leftJoin: () => _selectChain,
387 where: () => _selectChain,
388 orderBy: () => _selectChain,
389 limit: async () => {
390 const name = tableName(_lastSelectFrom);
391 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
392 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
393 if (name === "site_admins") return _nextAdminRow ? [_nextAdminRow] : [];
394 return [];
395 },
396 then: (resolve: (v: any) => void) => resolve([]),
397};
398
399const _fakeDb = {
400 db: {
401 select: () => _selectChain,
402 insert: () => ({
403 values: () => ({
404 returning: async () => [],
405 then: (r: (v: any) => void) => r(undefined),
406 }),
407 }),
408 update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
409 delete: () => ({ where: () => Promise.resolve() }),
410 },
411 getDb: () => _fakeDb.db,
412};
413
414mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
415
416const { default: app } = await import("../app");
417const { sessionCache } = await import("../lib/cache");
418const adminDeploys = await import("../routes/admin-deploys");
419
420const ADMIN_ID = "11111111-1111-1111-1111-111111111111";
421const NON_ADMIN_ID = "22222222-2222-2222-2222-222222222222";
422const ADMIN_TOKEN = "n4-admin-token";
423const NON_ADMIN_TOKEN = "n4-nonadmin-token";
424
425const ADMIN_USER = {
426 id: ADMIN_ID,
427 username: "admin_user",
428 displayName: "Admin",
429 email: "a@example.com",
430 passwordHash: "x",
431 createdAt: new Date(),
432 updatedAt: new Date(),
433};
434const NON_ADMIN_USER = {
435 id: NON_ADMIN_ID,
436 username: "nobody",
437 displayName: "Nobody",
438 email: "n@example.com",
439 passwordHash: "x",
440 createdAt: new Date(),
441 updatedAt: new Date(),
442};
443
444beforeEach(() => {
445 // softAuth reads sessions+users from DB OR sessionCache. Pre-warming the
446 // cache is enough; `isSiteAdmin` reads from `site_admins` table via _nextAdminRow.
447 sessionCache.set(ADMIN_TOKEN, ADMIN_USER as any);
448 sessionCache.set(NON_ADMIN_TOKEN, NON_ADMIN_USER as any);
449 _nextSessionRow = null;
450 _nextUserRow = null;
451 _nextAdminRow = null;
452});
453
454afterAll(() => {
455 sessionCache.invalidate(ADMIN_TOKEN);
456 sessionCache.invalidate(NON_ADMIN_TOKEN);
457 adminDeploys.__setGithubFetchForTests(null);
458 adminDeploys.__setEnvForTests(null);
459 mock.module("../db", () => _real_db);
460});
461
462// CSRF protection accepts a POST when either the Origin matches the host OR
463// a CSRF token cookie+header is supplied. We use the Origin-match path — set
464// `host: localhost` and `origin: http://localhost` and the middleware
465// recognises it as a same-origin request.
466const SAME_ORIGIN_HEADERS = {
467 host: "localhost",
468 origin: "http://localhost",
469};
470
471function authedPost(token: string, body: unknown): RequestInit {
472 return {
473 method: "POST",
474 headers: {
475 ...SAME_ORIGIN_HEADERS,
476 cookie: `session=${token}`,
477 "content-type": "application/json",
478 },
479 body: JSON.stringify(body),
480 };
481}
482
483describe("/admin/deploys/trigger", () => {
484 it("403s for an authed non-admin user", async () => {
485 _nextAdminRow = null;
486 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
487 adminDeploys.__setGithubFetchForTests(async () => noContent());
488
489 const res = await app.request(
490 "/admin/deploys/trigger",
491 authedPost(NON_ADMIN_TOKEN, {})
492 );
493 expect(res.status).toBe(403);
494 });
495
496 it("401s when not authenticated", async () => {
497 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
498 adminDeploys.__setGithubFetchForTests(async () => noContent());
499 const res = await app.request("/admin/deploys/trigger", {
500 method: "POST",
501 headers: { ...SAME_ORIGIN_HEADERS, "content-type": "application/json" },
502 body: JSON.stringify({}),
503 });
504 expect(res.status).toBe(401);
505 });
506
507 it("returns a helpful 400 when GITHUB_TOKEN is unset", async () => {
508 _nextAdminRow = { userId: ADMIN_ID }; // site admin
509 adminDeploys.__setEnvForTests({}); // no token
510 let called = false;
511 adminDeploys.__setGithubFetchForTests(async () => {
512 called = true;
513 return noContent();
514 });
515 const res = await app.request(
516 "/admin/deploys/trigger",
517 authedPost(ADMIN_TOKEN, {})
518 );
519 expect(res.status).toBe(400);
520 const body = await res.json();
521 expect(body.error).toMatch(/GITHUB_TOKEN/);
522 expect(called).toBe(false);
523 });
524
525 it("200s for admin and POSTs the dispatch to GitHub", async () => {
526 _nextAdminRow = { userId: ADMIN_ID };
527 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
528 let captured: { url: string; body?: string; method?: string } | null = null;
529 adminDeploys.__setGithubFetchForTests(async (url, init) => {
530 captured = { url, body: init?.body, method: init?.method };
531 return noContent();
532 });
533 const res = await app.request(
534 "/admin/deploys/trigger",
535 authedPost(ADMIN_TOKEN, {})
536 );
537 expect(res.status).toBe(200);
538 const body = await res.json();
539 expect(body.ok).toBe(true);
ffad5aaClaude540 // Default repo is the GitHub-side mirror (`ccantynz-alt`), NOT the
541 // Gluecron-side name (`ccantynz`). GitHub doesn't know `ccantynz`,
542 // so dispatching to that returned 404 in production until we fixed
543 // the default. See src/routes/admin-deploys.tsx for the comment.
544 expect(body.repo).toBe("ccantynz-alt/Gluecron.com");
f764c07Claude545 expect(body.workflow).toBe("hetzner-deploy.yml");
546 expect(body.ref).toBe("main");
547 expect(captured).not.toBeNull();
548 expect(captured!.method).toBe("POST");
549 expect(captured!.url).toContain("/actions/workflows/hetzner-deploy.yml/dispatches");
550 expect(JSON.parse(captured!.body!).ref).toBe("main");
551 });
552
553 it("502s when GitHub rejects the dispatch", async () => {
554 _nextAdminRow = { userId: ADMIN_ID };
555 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
556 adminDeploys.__setGithubFetchForTests(async () =>
557 jsonRes(422, { message: "No ref found" })
558 );
559 const res = await app.request(
560 "/admin/deploys/trigger",
561 authedPost(ADMIN_TOKEN, { ref: "no-such-branch" })
562 );
563 expect(res.status).toBe(502);
564 const body = await res.json();
565 expect(body.error).toMatch(/422.*No ref found/);
566 });
567});