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.tsBlame539 lines · 2 contributors
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();
5c7fbd2Claude289 // 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");
9dd96b9Test User292 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
315describe("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
347describe("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
391describe("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
469describe("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
477describe("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});