CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
admin-ops.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.
| 9dd96b9 | 1 | /** |
| 2 | * Block R1 — Tests for /admin/ops, the site-admin operations console. | |
| 3 | * | |
| 4 | * Coverage: | |
| 5 | * - GET /admin/ops requires site-admin (302 for anon, 403 for non-admin, | |
| 6 | * 200 HTML for admin) | |
| 7 | * - POST /admin/ops/auto-merge/enable calls runEnableAutoMerge with the | |
| 8 | * right args and redirects with success | |
| 9 | * - POST /admin/ops/auto-merge/disable passes `off:true` | |
| 10 | * - POST /admin/ops/deploy/trigger forwards to the N4 handler | |
| 11 | * - POST /admin/ops/rollback resolves the previous-successful SHA and | |
| 12 | * dispatches a workflow_dispatch with that ref | |
| 13 | * - findPreviousSuccessfulDeploy returns null when there's nothing prior | |
| 14 | * - triggerRollback maps 401 / 422 into friendly errors | |
| 15 | * | |
| 16 | * Mock pattern: K1-style `mock.module("../db", ...)` with afterAll | |
| 17 | * restoration. The opsRoutes module exposes `__setOpsDepsForTests` so we | |
| 18 | * inject the runEnableAutoMerge / triggerRollback / findPreviousSuccessfulDeploy | |
| 19 | * spies as actual collaborators — no need to stub Drizzle for the script's | |
| 20 | * private queries. | |
| 21 | */ | |
| 22 | ||
| 23 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 24 | ||
| 25 | // --------------------------------------------------------------------------- | |
| 26 | // Helpers shared with cli-deploy.test.ts. | |
| 27 | // --------------------------------------------------------------------------- | |
| 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 | function noContent() { | |
| 37 | return { status: 204, ok: true, text: async () => "" }; | |
| 38 | } | |
| 39 | ||
| 40 | // --------------------------------------------------------------------------- | |
| 41 | // Spread-from-real `../db` mock. We capture select returns per-test via | |
| 42 | // per-table "next row" hooks, identical to cli-deploy.test.ts. | |
| 43 | // --------------------------------------------------------------------------- | |
| 44 | ||
| 45 | const _real_db = await import("../db"); | |
| 46 | const _schema = await import("../db/schema"); | |
| 47 | const _schemaDeploys = await import("../db/schema-deploys"); | |
| 48 | ||
| 49 | let _nextSessionRow: any = null; | |
| 50 | let _nextUserRow: any = null; | |
| 51 | let _nextAdminRow: any = null; | |
| 52 | let _nextBpOwnerRow: any = null; // for readAutoMergeState owner lookup | |
| 53 | let _nextRepoRow: any = null; | |
| 54 | let _nextBpRow: any = null; | |
| 55 | let _nextLatestDeployRow: any = null; | |
| 56 | let _lastSelectFrom: any = null; | |
| 57 | let _userSelectCount = 0; | |
| 58 | ||
| 59 | const tableName = (t: any): string => { | |
| 60 | if (t === _schema.sessions) return "sessions"; | |
| 61 | if (t === _schema.users) return "users"; | |
| 62 | if (t === _schema.siteAdmins) return "site_admins"; | |
| 63 | if (t === _schema.repositories) return "repositories"; | |
| 64 | if (t === _schema.branchProtection) return "branch_protection"; | |
| 65 | if (t === _schemaDeploys.platformDeploys) return "platform_deploys"; | |
| 66 | return "?"; | |
| 67 | }; | |
| 68 | ||
| 69 | const _selectChain: any = { | |
| 70 | from: (t: any) => { | |
| 71 | _lastSelectFrom = t; | |
| 72 | if (tableName(t) === "users") _userSelectCount++; | |
| 73 | return _selectChain; | |
| 74 | }, | |
| 75 | innerJoin: () => _selectChain, | |
| 76 | leftJoin: () => _selectChain, | |
| 77 | where: () => _selectChain, | |
| 78 | orderBy: () => _selectChain, | |
| 79 | limit: async () => { | |
| 80 | const name = tableName(_lastSelectFrom); | |
| 81 | if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : []; | |
| 82 | if (name === "users") { | |
| 83 | // The softAuth path performs a users-select for the session lookup; | |
| 84 | // the page handler then does additional users-selects for the ops | |
| 85 | // repo owner. We let the first call be the session user and second+ | |
| 86 | // calls be the bp owner — the test sets both via setters below. | |
| 87 | if (_userSelectCount === 1) return _nextUserRow ? [_nextUserRow] : []; | |
| 88 | return _nextBpOwnerRow ? [_nextBpOwnerRow] : []; | |
| 89 | } | |
| 90 | if (name === "site_admins") return _nextAdminRow ? [_nextAdminRow] : []; | |
| 91 | if (name === "repositories") return _nextRepoRow ? [_nextRepoRow] : []; | |
| 92 | if (name === "branch_protection") | |
| 93 | return _nextBpRow ? [_nextBpRow] : []; | |
| 94 | if (name === "platform_deploys") | |
| 95 | return _nextLatestDeployRow ? [_nextLatestDeployRow] : []; | |
| 96 | return []; | |
| 97 | }, | |
| 98 | then: (resolve: (v: any) => void) => resolve([]), | |
| 99 | }; | |
| 100 | ||
| 101 | const _fakeDb = { | |
| 102 | db: { | |
| 103 | select: () => _selectChain, | |
| 104 | insert: () => ({ | |
| 105 | values: () => ({ | |
| 106 | returning: async () => [], | |
| 107 | then: (r: (v: any) => void) => r(undefined), | |
| 108 | }), | |
| 109 | }), | |
| 110 | update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), | |
| 111 | delete: () => ({ where: () => Promise.resolve() }), | |
| 112 | execute: async () => ({ rows: [{ column_name: "enable_auto_merge" }] }), | |
| 113 | }, | |
| 114 | getDb: () => _fakeDb.db, | |
| 115 | }; | |
| 116 | ||
| 117 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 118 | ||
| 119 | // Import the app + ops module AFTER mock.module has installed the fake. | |
| 120 | const { default: app } = await import("../app"); | |
| 121 | const { sessionCache } = await import("../lib/cache"); | |
| 122 | const opsModule = await import("../routes/admin-ops"); | |
| 123 | const adminDeploys = await import("../routes/admin-deploys"); | |
| 124 | const rollbackLib = await import("../lib/rollback-deploy"); | |
| 125 | ||
| 126 | // --------------------------------------------------------------------------- | |
| 127 | // Fake users + session tokens | |
| 128 | // --------------------------------------------------------------------------- | |
| 129 | ||
| 130 | const ADMIN_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; | |
| 131 | const NON_ADMIN_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; | |
| 132 | const ADMIN_TOKEN = "r1-admin-token"; | |
| 133 | const NON_ADMIN_TOKEN = "r1-nonadmin-token"; | |
| 134 | ||
| 135 | const ADMIN_USER = { | |
| 136 | id: ADMIN_ID, | |
| 137 | username: "ops_admin", | |
| 138 | displayName: "Ops Admin", | |
| 139 | email: "ops@example.com", | |
| 140 | passwordHash: "x", | |
| 141 | createdAt: new Date(), | |
| 142 | updatedAt: new Date(), | |
| 143 | }; | |
| 144 | const NON_ADMIN_USER = { | |
| 145 | id: NON_ADMIN_ID, | |
| 146 | username: "ops_nobody", | |
| 147 | displayName: "Nobody", | |
| 148 | email: "n@example.com", | |
| 149 | passwordHash: "x", | |
| 150 | createdAt: new Date(), | |
| 151 | updatedAt: new Date(), | |
| 152 | }; | |
| 153 | ||
| 154 | const SAME_ORIGIN_HEADERS = { | |
| 155 | host: "localhost", | |
| 156 | origin: "http://localhost", | |
| 157 | }; | |
| 158 | ||
| 159 | function authedPost(token: string): RequestInit { | |
| 160 | return { | |
| 161 | method: "POST", | |
| 162 | headers: { | |
| 163 | ...SAME_ORIGIN_HEADERS, | |
| 164 | cookie: `session=${token}`, | |
| 165 | "content-type": "application/json", | |
| 166 | }, | |
| 167 | body: JSON.stringify({}), | |
| 168 | redirect: "manual", | |
| 169 | }; | |
| 170 | } | |
| 171 | ||
| 172 | function authedGet(token: string | null): RequestInit { | |
| 173 | const headers: Record<string, string> = { ...SAME_ORIGIN_HEADERS }; | |
| 174 | if (token) headers.cookie = `session=${token}`; | |
| 175 | return { method: "GET", headers, redirect: "manual" }; | |
| 176 | } | |
| 177 | ||
| 178 | beforeEach(() => { | |
| 179 | sessionCache.set(ADMIN_TOKEN, ADMIN_USER as any); | |
| 180 | sessionCache.set(NON_ADMIN_TOKEN, NON_ADMIN_USER as any); | |
| 181 | _nextSessionRow = null; | |
| 182 | _nextUserRow = null; | |
| 183 | _nextAdminRow = null; | |
| 184 | _nextBpOwnerRow = null; | |
| 185 | _nextRepoRow = null; | |
| 186 | _nextBpRow = null; | |
| 187 | _nextLatestDeployRow = null; | |
| 188 | _userSelectCount = 0; | |
| 189 | opsModule.__setOpsDepsForTests(null); | |
| 190 | }); | |
| 191 | ||
| 192 | afterAll(() => { | |
| 193 | sessionCache.invalidate(ADMIN_TOKEN); | |
| 194 | sessionCache.invalidate(NON_ADMIN_TOKEN); | |
| 195 | opsModule.__setOpsDepsForTests(null); | |
| 196 | adminDeploys.__setGithubFetchForTests(null); | |
| 197 | adminDeploys.__setEnvForTests(null); | |
| 198 | mock.module("../db", () => _real_db); | |
| 199 | }); | |
| 200 | ||
| 201 | // =========================================================================== | |
| 202 | // GET /admin/ops gating | |
| 203 | // =========================================================================== | |
| 204 | ||
| 205 | describe("GET /admin/ops gating", () => { | |
| 206 | it("redirects anonymous users to /login", async () => { | |
| 207 | const res = await app.request("/admin/ops", authedGet(null)); | |
| 208 | expect([302, 303]).toContain(res.status); | |
| 209 | const loc = res.headers.get("location") || ""; | |
| 210 | expect(loc).toContain("/login"); | |
| 211 | }); | |
| 212 | ||
| 213 | it("403s an authed non-admin", async () => { | |
| 214 | _nextAdminRow = null; | |
| 215 | const res = await app.request( | |
| 216 | "/admin/ops", | |
| 217 | authedGet(NON_ADMIN_TOKEN) | |
| 218 | ); | |
| 219 | expect(res.status).toBe(403); | |
| 220 | }); | |
| 221 | ||
| 222 | it("renders HTML 200 for a site admin (auto-merge card present)", async () => { | |
| 223 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 224 | // Stub the readiness-friendly helpers so the page renders without | |
| 225 | // exercising the autopilot module-load probe. | |
| 226 | opsModule.__setOpsDepsForTests({ | |
| 227 | findPreviousSuccessfulDeploy: async () => null, | |
| 228 | }); | |
| 229 | const res = await app.request("/admin/ops", authedGet(ADMIN_TOKEN)); | |
| 230 | expect(res.status).toBe(200); | |
| 231 | const html = await res.text(); | |
| 232 | expect(html).toContain("AI auto-merge on main"); | |
| 233 | expect(html).toContain("Deploy"); | |
| 234 | expect(html).toContain("Rollback"); | |
| 235 | }); | |
| 236 | }); | |
| 237 | ||
| 238 | // =========================================================================== | |
| 239 | // POST /admin/ops/auto-merge/{enable,disable} | |
| 240 | // =========================================================================== | |
| 241 | ||
| 242 | describe("POST /admin/ops/auto-merge/enable", () => { | |
| 243 | it("redirects 401-equivalent to login when anonymous", async () => { | |
| 244 | const res = await app.request("/admin/ops/auto-merge/enable", { | |
| 245 | method: "POST", | |
| 246 | headers: SAME_ORIGIN_HEADERS, | |
| 247 | redirect: "manual", | |
| 248 | }); | |
| 249 | expect([302, 303]).toContain(res.status); | |
| 250 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 251 | }); | |
| 252 | ||
| 253 | it("403s for a non-admin", async () => { | |
| 254 | _nextAdminRow = null; | |
| 255 | const res = await app.request( | |
| 256 | "/admin/ops/auto-merge/enable", | |
| 257 | authedPost(NON_ADMIN_TOKEN) | |
| 258 | ); | |
| 259 | expect(res.status).toBe(403); | |
| 260 | }); | |
| 261 | ||
| 262 | it("calls runEnableAutoMerge with off=false + redirects success", async () => { | |
| 263 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 264 | let captured: any = null; | |
| 265 | opsModule.__setOpsDepsForTests({ | |
| 266 | runEnableAutoMerge: async (_db, args) => { | |
| 267 | captured = args; | |
| 268 | return { | |
| 269 | action: "updated", | |
| 270 | before: { enableAutoMerge: false } as any, | |
| 271 | after: { | |
| 272 | id: "bp-1", | |
| 273 | enableAutoMerge: true, | |
| 274 | } as any, | |
| 275 | auditWritten: true, | |
| 276 | }; | |
| 277 | }, | |
| 278 | }); | |
| 279 | const res = await app.request( | |
| 280 | "/admin/ops/auto-merge/enable", | |
| 281 | authedPost(ADMIN_TOKEN) | |
| 282 | ); | |
| 283 | expect([302, 303]).toContain(res.status); | |
| 284 | const loc = res.headers.get("location") || ""; | |
| 285 | expect(loc).toContain("/admin/ops"); | |
| 286 | expect(loc).toContain("success="); | |
| 287 | expect(loc.toLowerCase()).toContain("enabled"); | |
| 288 | expect(captured).not.toBeNull(); | |
| 5c7fbd2 | 289 | // Default OPS_REPO is the GitHub-mirror username (ccantynz-alt) the |
| 290 | // operator actually signed up with — env-overridable via SELF_HOST_REPO. | |
| 291 | expect(captured.ownerSlash).toBe("ccantynz-alt/Gluecron.com"); | |
| 9dd96b9 | 292 | expect(captured.pattern).toBe("main"); |
| 293 | expect(captured.off).toBe(false); | |
| 294 | expect(captured.actorUserId).toBe(ADMIN_ID); | |
| 295 | }); | |
| 296 | ||
| 297 | it("reports a friendly error when the script throws", async () => { | |
| 298 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 299 | opsModule.__setOpsDepsForTests({ | |
| 300 | runEnableAutoMerge: async () => { | |
| 301 | throw new Error("Repository not found: ccantynz/Gluecron.com."); | |
| 302 | }, | |
| 303 | }); | |
| 304 | const res = await app.request( | |
| 305 | "/admin/ops/auto-merge/enable", | |
| 306 | authedPost(ADMIN_TOKEN) | |
| 307 | ); | |
| 308 | expect([302, 303]).toContain(res.status); | |
| 309 | const loc = res.headers.get("location") || ""; | |
| 310 | expect(loc).toContain("error="); | |
| 311 | expect(decodeURIComponent(loc)).toMatch(/Repository not found/); | |
| 312 | }); | |
| 313 | }); | |
| 314 | ||
| 315 | describe("POST /admin/ops/auto-merge/disable", () => { | |
| 316 | it("calls the script with off=true + redirects success", async () => { | |
| 317 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 318 | let captured: any = null; | |
| 319 | opsModule.__setOpsDepsForTests({ | |
| 320 | runEnableAutoMerge: async (_db, args) => { | |
| 321 | captured = args; | |
| 322 | return { | |
| 323 | action: "updated", | |
| 324 | before: { enableAutoMerge: true } as any, | |
| 325 | after: { id: "bp-1", enableAutoMerge: false } as any, | |
| 326 | auditWritten: true, | |
| 327 | }; | |
| 328 | }, | |
| 329 | }); | |
| 330 | const res = await app.request( | |
| 331 | "/admin/ops/auto-merge/disable", | |
| 332 | authedPost(ADMIN_TOKEN) | |
| 333 | ); | |
| 334 | expect([302, 303]).toContain(res.status); | |
| 335 | expect(captured.off).toBe(true); | |
| 336 | expect(captured.actorUserId).toBe(ADMIN_ID); | |
| 337 | const loc = res.headers.get("location") || ""; | |
| 338 | expect(loc).toContain("success="); | |
| 339 | expect(decodeURIComponent(loc).toLowerCase()).toContain("disabled"); | |
| 340 | }); | |
| 341 | }); | |
| 342 | ||
| 343 | // =========================================================================== | |
| 344 | // POST /admin/ops/deploy/trigger — re-uses N4 internally | |
| 345 | // =========================================================================== | |
| 346 | ||
| 347 | describe("POST /admin/ops/deploy/trigger", () => { | |
| 348 | it("forwards to N4 and redirects success when GitHub returns 204", async () => { | |
| 349 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 350 | adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" }); | |
| 351 | let captured: { url: string; method?: string; body?: string } | null = null; | |
| 352 | adminDeploys.__setGithubFetchForTests(async (url, init) => { | |
| 353 | captured = { url, method: init?.method, body: init?.body }; | |
| 354 | return noContent(); | |
| 355 | }); | |
| 356 | const res = await app.request( | |
| 357 | "/admin/ops/deploy/trigger", | |
| 358 | authedPost(ADMIN_TOKEN) | |
| 359 | ); | |
| 360 | expect([302, 303]).toContain(res.status); | |
| 361 | const loc = res.headers.get("location") || ""; | |
| 362 | expect(loc).toContain("success="); | |
| 363 | expect(captured).not.toBeNull(); | |
| 364 | expect(captured!.method).toBe("POST"); | |
| 365 | expect(captured!.url).toContain( | |
| 366 | "/actions/workflows/hetzner-deploy.yml/dispatches" | |
| 367 | ); | |
| 368 | }); | |
| 369 | ||
| 370 | it("surfaces the N4 error when GitHub rejects the dispatch", async () => { | |
| 371 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 372 | adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" }); | |
| 373 | adminDeploys.__setGithubFetchForTests(async () => | |
| 374 | jsonRes(422, { message: "No ref found" }) | |
| 375 | ); | |
| 376 | const res = await app.request( | |
| 377 | "/admin/ops/deploy/trigger", | |
| 378 | authedPost(ADMIN_TOKEN) | |
| 379 | ); | |
| 380 | expect([302, 303]).toContain(res.status); | |
| 381 | const loc = res.headers.get("location") || ""; | |
| 382 | expect(loc).toContain("error="); | |
| 383 | expect(decodeURIComponent(loc)).toMatch(/422.*No ref found/); | |
| 384 | }); | |
| 385 | }); | |
| 386 | ||
| 387 | // =========================================================================== | |
| 388 | // POST /admin/ops/rollback | |
| 389 | // =========================================================================== | |
| 390 | ||
| 391 | describe("POST /admin/ops/rollback", () => { | |
| 392 | it("400-style redirect when there's no previous successful deploy", async () => { | |
| 393 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 394 | opsModule.__setOpsDepsForTests({ | |
| 395 | findPreviousSuccessfulDeploy: async () => null, | |
| 396 | }); | |
| 397 | const res = await app.request( | |
| 398 | "/admin/ops/rollback", | |
| 399 | authedPost(ADMIN_TOKEN) | |
| 400 | ); | |
| 401 | expect([302, 303]).toContain(res.status); | |
| 402 | const loc = res.headers.get("location") || ""; | |
| 403 | expect(loc).toContain("error="); | |
| 404 | expect(decodeURIComponent(loc)).toMatch(/No previous successful deploy/); | |
| 405 | }); | |
| 406 | ||
| 407 | it("calls triggerRollback with the previous-successful SHA on success", async () => { | |
| 408 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 409 | const PREV_SHA = "def56781234567890abcdef"; | |
| 410 | let capturedArgs: any = null; | |
| 411 | opsModule.__setOpsDepsForTests({ | |
| 412 | findPreviousSuccessfulDeploy: async () => ({ | |
| 413 | sha: PREV_SHA, | |
| 414 | runId: "9999", | |
| 415 | finishedAt: new Date(), | |
| 416 | }), | |
| 417 | triggerRollback: async (args) => { | |
| 418 | capturedArgs = args; | |
| 419 | return { ok: true, htmlUrl: "https://github.com/x/y/actions" }; | |
| 420 | }, | |
| 421 | }); | |
| 422 | const res = await app.request( | |
| 423 | "/admin/ops/rollback", | |
| 424 | authedPost(ADMIN_TOKEN) | |
| 425 | ); | |
| 426 | expect([302, 303]).toContain(res.status); | |
| 427 | expect(capturedArgs).not.toBeNull(); | |
| 428 | expect(capturedArgs.targetSha).toBe(PREV_SHA); | |
| 429 | expect(capturedArgs.triggeredByUserId).toBe(ADMIN_ID); | |
| 430 | const loc = res.headers.get("location") || ""; | |
| 431 | expect(loc).toContain("success="); | |
| 432 | expect(decodeURIComponent(loc)).toContain(PREV_SHA.slice(0, 7)); | |
| 433 | }); | |
| 434 | ||
| 435 | it("redirects with error when triggerRollback returns ok:false", async () => { | |
| 436 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 437 | opsModule.__setOpsDepsForTests({ | |
| 438 | findPreviousSuccessfulDeploy: async () => ({ | |
| 439 | sha: "abc1234", | |
| 440 | runId: "1", | |
| 441 | finishedAt: new Date(), | |
| 442 | }), | |
| 443 | triggerRollback: async () => ({ ok: false, error: "GitHub auth failed (401)" }), | |
| 444 | }); | |
| 445 | const res = await app.request( | |
| 446 | "/admin/ops/rollback", | |
| 447 | authedPost(ADMIN_TOKEN) | |
| 448 | ); | |
| 449 | expect([302, 303]).toContain(res.status); | |
| 450 | const loc = res.headers.get("location") || ""; | |
| 451 | expect(loc).toContain("error="); | |
| 452 | expect(decodeURIComponent(loc)).toMatch(/GitHub auth failed/); | |
| 453 | }); | |
| 454 | ||
| 455 | it("403s non-admins", async () => { | |
| 456 | _nextAdminRow = null; | |
| 457 | const res = await app.request( | |
| 458 | "/admin/ops/rollback", | |
| 459 | authedPost(NON_ADMIN_TOKEN) | |
| 460 | ); | |
| 461 | expect(res.status).toBe(403); | |
| 462 | }); | |
| 463 | }); | |
| 464 | ||
| 465 | // =========================================================================== | |
| 466 | // rollback-deploy.ts library helpers | |
| 467 | // =========================================================================== | |
| 468 | ||
| 469 | describe("findPreviousSuccessfulDeploy", () => { | |
| 470 | it("returns null when the table is empty", async () => { | |
| 471 | _nextLatestDeployRow = null; | |
| 472 | const r = await rollbackLib.findPreviousSuccessfulDeploy(); | |
| 473 | expect(r).toBeNull(); | |
| 474 | }); | |
| 475 | }); | |
| 476 | ||
| 477 | describe("triggerRollback — friendly error mapping", () => { | |
| 478 | it("rejects missing targetSha", async () => { | |
| 479 | const r = await rollbackLib.triggerRollback({ | |
| 480 | targetSha: "", | |
| 481 | triggeredByUserId: ADMIN_ID, | |
| 482 | githubToken: "ghp_x", | |
| 483 | fetchImpl: (async () => noContent()) as any, | |
| 484 | }); | |
| 485 | expect(r.ok).toBe(false); | |
| 486 | expect(r.error).toMatch(/targetSha is required/); | |
| 487 | }); | |
| 488 | ||
| 489 | it("rejects missing GITHUB_TOKEN", async () => { | |
| 490 | const r = await rollbackLib.triggerRollback({ | |
| 491 | targetSha: "abc1234", | |
| 492 | triggeredByUserId: ADMIN_ID, | |
| 493 | githubToken: "", | |
| 494 | fetchImpl: (async () => noContent()) as any, | |
| 495 | }); | |
| 496 | expect(r.ok).toBe(false); | |
| 497 | expect(r.error).toMatch(/GITHUB_TOKEN/); | |
| 498 | }); | |
| 499 | ||
| 500 | it("maps 401 → friendly auth error", async () => { | |
| 501 | const r = await rollbackLib.triggerRollback({ | |
| 502 | targetSha: "abc1234", | |
| 503 | triggeredByUserId: ADMIN_ID, | |
| 504 | githubToken: "ghp_bad", | |
| 505 | fetchImpl: (async () => | |
| 506 | jsonRes(401, { message: "Bad credentials" })) as any, | |
| 507 | }); | |
| 508 | expect(r.ok).toBe(false); | |
| 509 | expect(r.error).toMatch(/GitHub auth failed \(401\)/); | |
| 510 | }); | |
| 511 | ||
| 512 | it("maps 422 → friendly ref error", async () => { | |
| 513 | const r = await rollbackLib.triggerRollback({ | |
| 514 | targetSha: "nope", | |
| 515 | triggeredByUserId: ADMIN_ID, | |
| 516 | githubToken: "ghp_x", | |
| 517 | fetchImpl: (async () => | |
| 518 | jsonRes(422, { message: "No ref found for: nope" })) as any, | |
| 519 | }); | |
| 520 | expect(r.ok).toBe(false); | |
| 521 | expect(r.error).toMatch(/422.*No ref found/); | |
| 522 | }); | |
| 523 | ||
| 524 | it("ok:true on a 204 dispatch + POSTs ref=targetSha", async () => { | |
| 525 | const calls: Array<{ url: string; body?: string }> = []; | |
| 526 | const r = await rollbackLib.triggerRollback({ | |
| 527 | targetSha: "abc1234def", | |
| 528 | triggeredByUserId: ADMIN_ID, | |
| 529 | githubToken: "ghp_x", | |
| 530 | fetchImpl: (async (url: string, init?: any) => { | |
| 531 | calls.push({ url, body: init?.body }); | |
| 532 | return noContent(); | |
| 533 | }) as any, | |
| 534 | }); | |
| 535 | expect(r.ok).toBe(true); | |
| 536 | expect(calls[0]!.url).toContain("/dispatches"); | |
| 537 | expect(JSON.parse(calls[0]!.body!).ref).toBe("abc1234def"); | |
| 538 | }); | |
| 539 | }); |