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

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.

admin-ops.test.tsBlame537 lines · 1 contributor
9dd96b9Test User1/**
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
23import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
24
25// ---------------------------------------------------------------------------
26// Helpers shared with cli-deploy.test.ts.
27// ---------------------------------------------------------------------------
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}
36function 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
45const _real_db = await import("../db");
46const _schema = await import("../db/schema");
47const _schemaDeploys = await import("../db/schema-deploys");
48
49let _nextSessionRow: any = null;
50let _nextUserRow: any = null;
51let _nextAdminRow: any = null;
52let _nextBpOwnerRow: any = null; // for readAutoMergeState owner lookup
53let _nextRepoRow: any = null;
54let _nextBpRow: any = null;
55let _nextLatestDeployRow: any = null;
56let _lastSelectFrom: any = null;
57let _userSelectCount = 0;
58
59const 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
69const _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
101const _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
117mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
118
119// Import the app + ops module AFTER mock.module has installed the fake.
120const { default: app } = await import("../app");
121const { sessionCache } = await import("../lib/cache");
122const opsModule = await import("../routes/admin-ops");
123const adminDeploys = await import("../routes/admin-deploys");
124const rollbackLib = await import("../lib/rollback-deploy");
125
126// ---------------------------------------------------------------------------
127// Fake users + session tokens
128// ---------------------------------------------------------------------------
129
130const ADMIN_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
131const NON_ADMIN_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
132const ADMIN_TOKEN = "r1-admin-token";
133const NON_ADMIN_TOKEN = "r1-nonadmin-token";
134
135const 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};
144const 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
154const SAME_ORIGIN_HEADERS = {
155 host: "localhost",
156 origin: "http://localhost",
157};
158
159function 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
172function 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
178beforeEach(() => {
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
192afterAll(() => {
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
205describe("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
242describe("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();
289 expect(captured.ownerSlash).toBe("ccantynz/Gluecron.com");
290 expect(captured.pattern).toBe("main");
291 expect(captured.off).toBe(false);
292 expect(captured.actorUserId).toBe(ADMIN_ID);
293 });
294
295 it("reports a friendly error when the script throws", async () => {
296 _nextAdminRow = { userId: ADMIN_ID };
297 opsModule.__setOpsDepsForTests({
298 runEnableAutoMerge: async () => {
299 throw new Error("Repository not found: ccantynz/Gluecron.com.");
300 },
301 });
302 const res = await app.request(
303 "/admin/ops/auto-merge/enable",
304 authedPost(ADMIN_TOKEN)
305 );
306 expect([302, 303]).toContain(res.status);
307 const loc = res.headers.get("location") || "";
308 expect(loc).toContain("error=");
309 expect(decodeURIComponent(loc)).toMatch(/Repository not found/);
310 });
311});
312
313describe("POST /admin/ops/auto-merge/disable", () => {
314 it("calls the script with off=true + redirects success", async () => {
315 _nextAdminRow = { userId: ADMIN_ID };
316 let captured: any = null;
317 opsModule.__setOpsDepsForTests({
318 runEnableAutoMerge: async (_db, args) => {
319 captured = args;
320 return {
321 action: "updated",
322 before: { enableAutoMerge: true } as any,
323 after: { id: "bp-1", enableAutoMerge: false } as any,
324 auditWritten: true,
325 };
326 },
327 });
328 const res = await app.request(
329 "/admin/ops/auto-merge/disable",
330 authedPost(ADMIN_TOKEN)
331 );
332 expect([302, 303]).toContain(res.status);
333 expect(captured.off).toBe(true);
334 expect(captured.actorUserId).toBe(ADMIN_ID);
335 const loc = res.headers.get("location") || "";
336 expect(loc).toContain("success=");
337 expect(decodeURIComponent(loc).toLowerCase()).toContain("disabled");
338 });
339});
340
341// ===========================================================================
342// POST /admin/ops/deploy/trigger — re-uses N4 internally
343// ===========================================================================
344
345describe("POST /admin/ops/deploy/trigger", () => {
346 it("forwards to N4 and redirects success when GitHub returns 204", async () => {
347 _nextAdminRow = { userId: ADMIN_ID };
348 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
349 let captured: { url: string; method?: string; body?: string } | null = null;
350 adminDeploys.__setGithubFetchForTests(async (url, init) => {
351 captured = { url, method: init?.method, body: init?.body };
352 return noContent();
353 });
354 const res = await app.request(
355 "/admin/ops/deploy/trigger",
356 authedPost(ADMIN_TOKEN)
357 );
358 expect([302, 303]).toContain(res.status);
359 const loc = res.headers.get("location") || "";
360 expect(loc).toContain("success=");
361 expect(captured).not.toBeNull();
362 expect(captured!.method).toBe("POST");
363 expect(captured!.url).toContain(
364 "/actions/workflows/hetzner-deploy.yml/dispatches"
365 );
366 });
367
368 it("surfaces the N4 error when GitHub rejects the dispatch", async () => {
369 _nextAdminRow = { userId: ADMIN_ID };
370 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
371 adminDeploys.__setGithubFetchForTests(async () =>
372 jsonRes(422, { message: "No ref found" })
373 );
374 const res = await app.request(
375 "/admin/ops/deploy/trigger",
376 authedPost(ADMIN_TOKEN)
377 );
378 expect([302, 303]).toContain(res.status);
379 const loc = res.headers.get("location") || "";
380 expect(loc).toContain("error=");
381 expect(decodeURIComponent(loc)).toMatch(/422.*No ref found/);
382 });
383});
384
385// ===========================================================================
386// POST /admin/ops/rollback
387// ===========================================================================
388
389describe("POST /admin/ops/rollback", () => {
390 it("400-style redirect when there's no previous successful deploy", async () => {
391 _nextAdminRow = { userId: ADMIN_ID };
392 opsModule.__setOpsDepsForTests({
393 findPreviousSuccessfulDeploy: async () => null,
394 });
395 const res = await app.request(
396 "/admin/ops/rollback",
397 authedPost(ADMIN_TOKEN)
398 );
399 expect([302, 303]).toContain(res.status);
400 const loc = res.headers.get("location") || "";
401 expect(loc).toContain("error=");
402 expect(decodeURIComponent(loc)).toMatch(/No previous successful deploy/);
403 });
404
405 it("calls triggerRollback with the previous-successful SHA on success", async () => {
406 _nextAdminRow = { userId: ADMIN_ID };
407 const PREV_SHA = "def56781234567890abcdef";
408 let capturedArgs: any = null;
409 opsModule.__setOpsDepsForTests({
410 findPreviousSuccessfulDeploy: async () => ({
411 sha: PREV_SHA,
412 runId: "9999",
413 finishedAt: new Date(),
414 }),
415 triggerRollback: async (args) => {
416 capturedArgs = args;
417 return { ok: true, htmlUrl: "https://github.com/x/y/actions" };
418 },
419 });
420 const res = await app.request(
421 "/admin/ops/rollback",
422 authedPost(ADMIN_TOKEN)
423 );
424 expect([302, 303]).toContain(res.status);
425 expect(capturedArgs).not.toBeNull();
426 expect(capturedArgs.targetSha).toBe(PREV_SHA);
427 expect(capturedArgs.triggeredByUserId).toBe(ADMIN_ID);
428 const loc = res.headers.get("location") || "";
429 expect(loc).toContain("success=");
430 expect(decodeURIComponent(loc)).toContain(PREV_SHA.slice(0, 7));
431 });
432
433 it("redirects with error when triggerRollback returns ok:false", async () => {
434 _nextAdminRow = { userId: ADMIN_ID };
435 opsModule.__setOpsDepsForTests({
436 findPreviousSuccessfulDeploy: async () => ({
437 sha: "abc1234",
438 runId: "1",
439 finishedAt: new Date(),
440 }),
441 triggerRollback: async () => ({ ok: false, error: "GitHub auth failed (401)" }),
442 });
443 const res = await app.request(
444 "/admin/ops/rollback",
445 authedPost(ADMIN_TOKEN)
446 );
447 expect([302, 303]).toContain(res.status);
448 const loc = res.headers.get("location") || "";
449 expect(loc).toContain("error=");
450 expect(decodeURIComponent(loc)).toMatch(/GitHub auth failed/);
451 });
452
453 it("403s non-admins", async () => {
454 _nextAdminRow = null;
455 const res = await app.request(
456 "/admin/ops/rollback",
457 authedPost(NON_ADMIN_TOKEN)
458 );
459 expect(res.status).toBe(403);
460 });
461});
462
463// ===========================================================================
464// rollback-deploy.ts library helpers
465// ===========================================================================
466
467describe("findPreviousSuccessfulDeploy", () => {
468 it("returns null when the table is empty", async () => {
469 _nextLatestDeployRow = null;
470 const r = await rollbackLib.findPreviousSuccessfulDeploy();
471 expect(r).toBeNull();
472 });
473});
474
475describe("triggerRollback — friendly error mapping", () => {
476 it("rejects missing targetSha", async () => {
477 const r = await rollbackLib.triggerRollback({
478 targetSha: "",
479 triggeredByUserId: ADMIN_ID,
480 githubToken: "ghp_x",
481 fetchImpl: (async () => noContent()) as any,
482 });
483 expect(r.ok).toBe(false);
484 expect(r.error).toMatch(/targetSha is required/);
485 });
486
487 it("rejects missing GITHUB_TOKEN", async () => {
488 const r = await rollbackLib.triggerRollback({
489 targetSha: "abc1234",
490 triggeredByUserId: ADMIN_ID,
491 githubToken: "",
492 fetchImpl: (async () => noContent()) as any,
493 });
494 expect(r.ok).toBe(false);
495 expect(r.error).toMatch(/GITHUB_TOKEN/);
496 });
497
498 it("maps 401 → friendly auth error", async () => {
499 const r = await rollbackLib.triggerRollback({
500 targetSha: "abc1234",
501 triggeredByUserId: ADMIN_ID,
502 githubToken: "ghp_bad",
503 fetchImpl: (async () =>
504 jsonRes(401, { message: "Bad credentials" })) as any,
505 });
506 expect(r.ok).toBe(false);
507 expect(r.error).toMatch(/GitHub auth failed \(401\)/);
508 });
509
510 it("maps 422 → friendly ref error", async () => {
511 const r = await rollbackLib.triggerRollback({
512 targetSha: "nope",
513 triggeredByUserId: ADMIN_ID,
514 githubToken: "ghp_x",
515 fetchImpl: (async () =>
516 jsonRes(422, { message: "No ref found for: nope" })) as any,
517 });
518 expect(r.ok).toBe(false);
519 expect(r.error).toMatch(/422.*No ref found/);
520 });
521
522 it("ok:true on a 204 dispatch + POSTs ref=targetSha", async () => {
523 const calls: Array<{ url: string; body?: string }> = [];
524 const r = await rollbackLib.triggerRollback({
525 targetSha: "abc1234def",
526 triggeredByUserId: ADMIN_ID,
527 githubToken: "ghp_x",
528 fetchImpl: (async (url: string, init?: any) => {
529 calls.push({ url, body: init?.body });
530 return noContent();
531 }) as any,
532 });
533 expect(r.ok).toBe(true);
534 expect(calls[0]!.url).toContain("/dispatches");
535 expect(JSON.parse(calls[0]!.body!).ref).toBe("abc1234def");
536 });
537});