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.
| f764c07 | 1 | /** |
| 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 | ||
| 18 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 19 | ||
| 20 | import { | |
| 21 | triggerWorkflowDispatch, | |
| 22 | watchDeploy, | |
| 23 | handleDeployCmd, | |
| 24 | type FetchLike, | |
| 25 | } from "../../cli/gluecron"; | |
| 26 | ||
| 27 | // ---------- helpers --------------------------------------------------------- | |
| 28 | ||
| 29 | function 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 | ||
| 37 | function noContent() { | |
| 38 | return { | |
| 39 | status: 204, | |
| 40 | ok: true, | |
| 41 | text: async () => "", | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | function 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 | ||
| 58 | describe("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 | ||
| 160 | describe("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 | ||
| 222 | describe("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 | ||
| 360 | const _real_db = await import("../db"); | |
| 361 | const _schema = await import("../db/schema"); | |
| 362 | ||
| 363 | // Per-test row hooks (matching the layout-user-prop.test.ts pattern). | |
| 364 | let _nextSessionRow: any = null; | |
| 365 | let _nextUserRow: any = null; | |
| 366 | let _nextAdminRow: any = null; | |
| 367 | let _lastSelectFrom: any = null; | |
| 368 | ||
| 369 | const 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 | ||
| 380 | const _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 | ||
| 399 | const _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 | ||
| 414 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 415 | ||
| 416 | const { default: app } = await import("../app"); | |
| 417 | const { sessionCache } = await import("../lib/cache"); | |
| 418 | const adminDeploys = await import("../routes/admin-deploys"); | |
| 419 | ||
| 420 | const ADMIN_ID = "11111111-1111-1111-1111-111111111111"; | |
| 421 | const NON_ADMIN_ID = "22222222-2222-2222-2222-222222222222"; | |
| 422 | const ADMIN_TOKEN = "n4-admin-token"; | |
| 423 | const NON_ADMIN_TOKEN = "n4-nonadmin-token"; | |
| 424 | ||
| 425 | const 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 | }; | |
| 434 | const 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 | ||
| 444 | beforeEach(() => { | |
| 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 | ||
| 454 | afterAll(() => { | |
| 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. | |
| 466 | const SAME_ORIGIN_HEADERS = { | |
| 467 | host: "localhost", | |
| 468 | origin: "http://localhost", | |
| 469 | }; | |
| 470 | ||
| 471 | function 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 | ||
| 483 | describe("/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); | |
| ffad5aa | 540 | // 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"); | |
| f764c07 | 545 | 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 | }); |