Blame · Line-by-line history
synthetic-monitor.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.
| b1be050 | 1 | /** |
| 2 | * BLOCK S4 — Synthetic monitor tests. | |
| 3 | * | |
| 4 | * Covers: | |
| 5 | * - runSyntheticChecks returns one result per check | |
| 6 | * - green on 200 + expected key / expected substring | |
| 7 | * - red on wrong status / missing key / contains-failure / fetch-throw | |
| 8 | * - persistChecks inserts rows + publishes SSE | |
| 9 | * - latestStatusByCheck returns the most-recent row per name (via stub) | |
| 10 | * - runSyntheticMonitorTaskOnce fires webhook only on green->red | |
| 11 | * - /admin/status renders for site-admin, 403s for non-admin | |
| 12 | * | |
| 13 | * DI is via injected fetch + injected DB module. We follow the K1 | |
| 14 | * spread-from-real pattern with afterAll cleanup so this file does not | |
| 15 | * poison downstream tests in the same `bun test` invocation. | |
| 16 | */ | |
| 17 | ||
| 18 | import { | |
| 19 | describe, | |
| 20 | it, | |
| 21 | expect, | |
| 22 | mock, | |
| 23 | beforeEach, | |
| 24 | afterAll, | |
| 25 | } from "bun:test"; | |
| 26 | ||
| 27 | // Capture real modules before any mock.module() call so we can restore. | |
| 28 | const _real_db = await import("../db"); | |
| 29 | const _real_admin = await import("../lib/admin"); | |
| 30 | ||
| 31 | // --------------------------------------------------------------------------- | |
| 32 | // Fakes | |
| 33 | // --------------------------------------------------------------------------- | |
| 34 | ||
| 35 | const _inserted: { table: string; values: any }[] = []; | |
| 36 | let _latestRows: any[] = []; | |
| 37 | let _recentRedRows: any[] = []; | |
| 38 | ||
| 39 | const tableName = (t: any): string => { | |
| 40 | if (!t) return "?"; | |
| 41 | if ("checkName" in t) return "synthetic_checks"; | |
| 42 | return "?"; | |
| 43 | }; | |
| 44 | ||
| 45 | const _fakeDb = { | |
| 46 | db: { | |
| 47 | insert: (table: any) => ({ | |
| 48 | values: async (vals: any) => { | |
| 49 | _inserted.push({ table: tableName(table), values: vals }); | |
| 50 | return []; | |
| 51 | }, | |
| 52 | }), | |
| 53 | select: (_cols?: any) => { | |
| 54 | const builder: any = { | |
| 55 | from: (_t: any) => builder, | |
| 56 | where: (_w: any) => builder, | |
| 57 | orderBy: (_o: any) => builder, | |
| 58 | limit: (_n: number) => Promise.resolve(_recentRedRows), | |
| 59 | }; | |
| 60 | return builder; | |
| 61 | }, | |
| 62 | execute: async (_q: any) => { | |
| 63 | // Drizzle returns either an array or { rows: [...] } depending on | |
| 64 | // the driver. We mimic the Neon serverless shape. | |
| 65 | return _latestRows; | |
| 66 | }, | |
| 67 | }, | |
| 68 | }; | |
| 69 | ||
| 70 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 71 | ||
| 72 | afterAll(() => { | |
| 73 | mock.module("../db", () => _real_db); | |
| 74 | _latestRows = []; | |
| 75 | _recentRedRows = []; | |
| 76 | _inserted.length = 0; | |
| 77 | }); | |
| 78 | ||
| 79 | beforeEach(() => { | |
| 80 | _inserted.length = 0; | |
| 81 | _latestRows = []; | |
| 82 | _recentRedRows = []; | |
| 83 | }); | |
| 84 | ||
| 85 | // Imports must come AFTER mock.module() so the loaded module uses the | |
| 86 | // stubbed `db`. | |
| 87 | const { | |
| 88 | runSyntheticChecks, | |
| 89 | persistChecks, | |
| 90 | latestStatusByCheck, | |
| 91 | SYNTHETIC_CHECKS, | |
| 92 | SSE_TOPIC, | |
| 93 | __test, | |
| 94 | } = await import("../lib/synthetic-monitor"); | |
| 95 | const { runSyntheticMonitorTaskOnce } = await import("../lib/autopilot"); | |
| 96 | const sseMod = await import("../lib/sse"); | |
| 97 | ||
| 98 | // --------------------------------------------------------------------------- | |
| 99 | // Helpers | |
| 100 | // --------------------------------------------------------------------------- | |
| 101 | ||
| 102 | function jsonResp(body: unknown, status = 200): Response { | |
| 103 | return new Response(JSON.stringify(body), { | |
| 104 | status, | |
| 105 | headers: { "content-type": "application/json" }, | |
| 106 | }); | |
| 107 | } | |
| 108 | ||
| 109 | function textResp(body: string, status = 200): Response { | |
| 110 | return new Response(body, { | |
| 111 | status, | |
| 112 | headers: { "content-type": "text/html" }, | |
| 113 | }); | |
| 114 | } | |
| 115 | ||
| 116 | function makeFetch( | |
| 117 | responder: (url: string) => Response | Promise<Response> | |
| 118 | ): typeof fetch { | |
| 119 | return (async (input: any) => { | |
| 120 | const url = String(input); | |
| 121 | return responder(url); | |
| 122 | }) as unknown as typeof fetch; | |
| 123 | } | |
| 124 | ||
| 125 | // --------------------------------------------------------------------------- | |
| 126 | // runSyntheticChecks — happy paths | |
| 127 | // --------------------------------------------------------------------------- | |
| 128 | ||
| 129 | describe("synthetic-monitor — runSyntheticChecks", () => { | |
| 130 | it("returns exactly one result per entry in SYNTHETIC_CHECKS", async () => { | |
| 131 | const fetchImpl = makeFetch((url) => { | |
| 132 | // Generic green-everything responder. | |
| 133 | if (url.endsWith("/healthz")) return jsonResp({ ok: true }); | |
| 134 | if (url.endsWith("/api/version")) return jsonResp({ sha: "abc123" }); | |
| 135 | if (url.endsWith("/mcp")) return jsonResp({ serverInfo: { name: "g" } }); | |
| 136 | if (url.endsWith("/login")) return textResp("Sign in to your account"); | |
| 137 | if (url.endsWith("/register")) return textResp("Create account today"); | |
| 138 | return textResp("ok"); | |
| 139 | }); | |
| 140 | const results = await runSyntheticChecks({ | |
| 141 | baseUrl: "http://localhost:3000", | |
| 142 | fetchImpl, | |
| 143 | }); | |
| 144 | expect(results.length).toBe(SYNTHETIC_CHECKS.length); | |
| 145 | const names = results.map((r) => r.name).sort(); | |
| 146 | const expected = SYNTHETIC_CHECKS.map((s) => s.name).sort(); | |
| 147 | expect(names).toEqual(expected); | |
| 148 | }); | |
| 149 | ||
| 150 | it("marks a check green on 200 + expected key in JSON", async () => { | |
| 151 | const fetchImpl = makeFetch(() => jsonResp({ ok: true })); | |
| 152 | const results = await runSyntheticChecks({ | |
| 153 | baseUrl: "http://x", | |
| 154 | fetchImpl, | |
| 155 | checks: [ | |
| 156 | { name: "probe", url: "/probe", expectKeyInJson: "ok" }, | |
| 157 | ], | |
| 158 | }); | |
| 159 | expect(results[0]).toMatchObject({ | |
| 160 | name: "probe", | |
| 161 | status: "green", | |
| 162 | statusCode: 200, | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | it("marks a check green on 200 + expectContains match", async () => { | |
| 167 | const fetchImpl = makeFetch(() => textResp("Hello Sign in friend")); | |
| 168 | const results = await runSyntheticChecks({ | |
| 169 | baseUrl: "http://x", | |
| 170 | fetchImpl, | |
| 171 | checks: [{ name: "login", url: "/login", expectContains: "Sign in" }], | |
| 172 | }); | |
| 173 | expect(results[0].status).toBe("green"); | |
| 174 | }); | |
| 175 | }); | |
| 176 | ||
| 177 | // --------------------------------------------------------------------------- | |
| 178 | // runSyntheticChecks — red branches | |
| 179 | // --------------------------------------------------------------------------- | |
| 180 | ||
| 181 | describe("synthetic-monitor — failure branches", () => { | |
| 182 | it("red on wrong status code", async () => { | |
| 183 | const fetchImpl = makeFetch(() => textResp("nope", 500)); | |
| 184 | const results = await runSyntheticChecks({ | |
| 185 | baseUrl: "http://x", | |
| 186 | fetchImpl, | |
| 187 | checks: [{ name: "landing", url: "/" }], | |
| 188 | }); | |
| 189 | expect(results[0].status).toBe("red"); | |
| 190 | expect(results[0].statusCode).toBe(500); | |
| 191 | expect(results[0].error).toContain("500"); | |
| 192 | }); | |
| 193 | ||
| 194 | it("red on missing expected JSON key", async () => { | |
| 195 | const fetchImpl = makeFetch(() => jsonResp({ status: "fine" })); | |
| 196 | const results = await runSyntheticChecks({ | |
| 197 | baseUrl: "http://x", | |
| 198 | fetchImpl, | |
| 199 | checks: [{ name: "v", url: "/v", expectKeyInJson: "sha" }], | |
| 200 | }); | |
| 201 | expect(results[0].status).toBe("red"); | |
| 202 | expect(results[0].error).toContain("sha"); | |
| 203 | }); | |
| 204 | ||
| 205 | it("red on non-JSON body when JSON was expected", async () => { | |
| 206 | const fetchImpl = makeFetch(() => textResp("<html>oops</html>")); | |
| 207 | const results = await runSyntheticChecks({ | |
| 208 | baseUrl: "http://x", | |
| 209 | fetchImpl, | |
| 210 | checks: [{ name: "v", url: "/v", expectKeyInJson: "sha" }], | |
| 211 | }); | |
| 212 | expect(results[0].status).toBe("red"); | |
| 213 | expect(results[0].error).toContain("non-JSON"); | |
| 214 | }); | |
| 215 | ||
| 216 | it("red on missing expectContains substring", async () => { | |
| 217 | const fetchImpl = makeFetch(() => textResp("nothing here")); | |
| 218 | const results = await runSyntheticChecks({ | |
| 219 | baseUrl: "http://x", | |
| 220 | fetchImpl, | |
| 221 | checks: [ | |
| 222 | { name: "login", url: "/login", expectContains: "Sign in" }, | |
| 223 | ], | |
| 224 | }); | |
| 225 | expect(results[0].status).toBe("red"); | |
| 226 | expect(results[0].error).toContain("Sign in"); | |
| 227 | }); | |
| 228 | ||
| 229 | it("red on fetch-throw (network error / DNS) with the error captured", async () => { | |
| 230 | const fetchImpl = makeFetch(() => { | |
| 231 | throw new Error("getaddrinfo ENOTFOUND example.invalid"); | |
| 232 | }); | |
| 233 | const results = await runSyntheticChecks({ | |
| 234 | baseUrl: "http://x", | |
| 235 | fetchImpl, | |
| 236 | checks: [{ name: "landing", url: "/" }], | |
| 237 | }); | |
| 238 | expect(results[0].status).toBe("red"); | |
| 239 | expect(results[0].error).toContain("ENOTFOUND"); | |
| 240 | expect(results[0].statusCode).toBeUndefined(); | |
| 241 | }); | |
| 242 | ||
| 243 | it("red on AbortError (timeout)", async () => { | |
| 244 | const fetchImpl = makeFetch(() => { | |
| 245 | const err = new Error("timed out"); | |
| 246 | (err as any).name = "AbortError"; | |
| 247 | throw err; | |
| 248 | }); | |
| 249 | const results = await runSyntheticChecks({ | |
| 250 | baseUrl: "http://x", | |
| 251 | fetchImpl, | |
| 252 | checks: [ | |
| 253 | { name: "slow", url: "/slow", timeoutMs: 10 }, | |
| 254 | ], | |
| 255 | }); | |
| 256 | expect(results[0].status).toBe("red"); | |
| 257 | expect(results[0].error).toContain("timeout"); | |
| 258 | }); | |
| 259 | }); | |
| 260 | ||
| 261 | // --------------------------------------------------------------------------- | |
| 262 | // persistChecks — DB insert + SSE publish | |
| 263 | // --------------------------------------------------------------------------- | |
| 264 | ||
| 265 | describe("synthetic-monitor — persistChecks", () => { | |
| 266 | it("inserts a row per result and publishes one SSE event per result", async () => { | |
| 267 | const published: Array<{ topic: string; event: any }> = []; | |
| 268 | const unsub = sseMod.subscribe(SSE_TOPIC, (ev) => { | |
| 269 | published.push({ topic: SSE_TOPIC, event: ev }); | |
| 270 | }); | |
| 271 | try { | |
| 272 | await persistChecks([ | |
| 273 | { name: "a", status: "green", statusCode: 200, durationMs: 12 }, | |
| 274 | { | |
| 275 | name: "b", | |
| 276 | status: "red", | |
| 277 | statusCode: 500, | |
| 278 | durationMs: 42, | |
| 279 | error: "boom", | |
| 280 | }, | |
| 281 | ]); | |
| 282 | expect(_inserted.length).toBe(1); | |
| 283 | expect(_inserted[0].table).toBe("synthetic_checks"); | |
| 284 | expect(_inserted[0].values).toHaveLength(2); | |
| 285 | expect(_inserted[0].values[0]).toMatchObject({ | |
| 286 | checkName: "a", | |
| 287 | status: "green", | |
| 288 | statusCode: 200, | |
| 289 | }); | |
| 290 | expect(published.length).toBe(2); | |
| 291 | expect((published[0].event.data as any).name).toBe("a"); | |
| 292 | expect((published[1].event.data as any).name).toBe("b"); | |
| 293 | } finally { | |
| 294 | unsub(); | |
| 295 | } | |
| 296 | }); | |
| 297 | ||
| 298 | it("no-ops on an empty result list", async () => { | |
| 299 | await persistChecks([]); | |
| 300 | expect(_inserted.length).toBe(0); | |
| 301 | }); | |
| 302 | }); | |
| 303 | ||
| 304 | // --------------------------------------------------------------------------- | |
| 305 | // latestStatusByCheck — returns the most-recent row per name | |
| 306 | // --------------------------------------------------------------------------- | |
| 307 | ||
| 308 | describe("synthetic-monitor — latestStatusByCheck", () => { | |
| 309 | it("maps DISTINCT ON rows into a per-name dictionary", async () => { | |
| 310 | const now = new Date(); | |
| 311 | _latestRows = [ | |
| 312 | { | |
| 313 | check_name: "healthz", | |
| 314 | status: "green", | |
| 315 | status_code: 200, | |
| 316 | duration_ms: 14, | |
| 317 | error: null, | |
| 318 | checked_at: now, | |
| 319 | }, | |
| 320 | { | |
| 321 | check_name: "login", | |
| 322 | status: "red", | |
| 323 | status_code: 500, | |
| 324 | duration_ms: 312, | |
| 325 | error: "boom", | |
| 326 | checked_at: now, | |
| 327 | }, | |
| 328 | ]; | |
| 329 | const out = await latestStatusByCheck(); | |
| 330 | expect(Object.keys(out).sort()).toEqual(["healthz", "login"]); | |
| 331 | expect(out.healthz.status).toBe("green"); | |
| 332 | expect(out.login.status).toBe("red"); | |
| 333 | expect(out.login.error).toBe("boom"); | |
| 334 | expect(out.login.statusCode).toBe(500); | |
| 335 | }); | |
| 336 | ||
| 337 | it("returns {} on DB error", async () => { | |
| 338 | // Force execute to throw by re-mocking once. | |
| 339 | const orig = (_fakeDb.db as any).execute; | |
| 340 | (_fakeDb.db as any).execute = async () => { | |
| 341 | throw new Error("connection refused"); | |
| 342 | }; | |
| 343 | try { | |
| 344 | const out = await latestStatusByCheck(); | |
| 345 | expect(out).toEqual({}); | |
| 346 | } finally { | |
| 347 | (_fakeDb.db as any).execute = orig; | |
| 348 | } | |
| 349 | }); | |
| 350 | }); | |
| 351 | ||
| 352 | // --------------------------------------------------------------------------- | |
| 353 | // runSyntheticMonitorTaskOnce — webhook transitions | |
| 354 | // --------------------------------------------------------------------------- | |
| 355 | ||
| 356 | describe("synthetic-monitor — webhook on green->red transition", () => { | |
| 357 | it("fires the webhook only on green->red, not on red->red repeats", async () => { | |
| 358 | const alertCalls: any[] = []; | |
| 359 | ||
| 360 | // First run: previous=green, current=red -> webhook fires. | |
| 361 | const summary1 = await runSyntheticMonitorTaskOnce({ | |
| 362 | runChecks: async () => [ | |
| 363 | { name: "login", status: "red", statusCode: 500, durationMs: 10, error: "500" }, | |
| 364 | { name: "healthz", status: "green", statusCode: 200, durationMs: 5 }, | |
| 365 | ], | |
| 366 | persist: async () => {}, | |
| 367 | loadPrevious: async () => ({ | |
| 368 | login: { name: "login", status: "green", statusCode: 200, durationMs: 12 }, | |
| 369 | healthz: { name: "healthz", status: "green", statusCode: 200, durationMs: 5 }, | |
| 370 | }), | |
| 371 | postAlert: async (url, payload) => { | |
| 372 | alertCalls.push({ url, payload }); | |
| 373 | }, | |
| 374 | alertUrl: () => "http://alerts.example.com/hook", | |
| 375 | }); | |
| 376 | expect(summary1.transitions).toBe(1); | |
| 377 | expect(summary1.red).toBe(1); | |
| 378 | expect(summary1.green).toBe(1); | |
| 379 | expect(alertCalls.length).toBe(1); | |
| 380 | expect(alertCalls[0].payload.check).toBe("login"); | |
| 381 | expect(alertCalls[0].payload.error).toBe("500"); | |
| 382 | ||
| 383 | // Second run: prior=red, current=red -> NO webhook. | |
| 384 | alertCalls.length = 0; | |
| 385 | const summary2 = await runSyntheticMonitorTaskOnce({ | |
| 386 | runChecks: async () => [ | |
| 387 | { name: "login", status: "red", statusCode: 500, durationMs: 10, error: "still 500" }, | |
| 388 | ], | |
| 389 | persist: async () => {}, | |
| 390 | loadPrevious: async () => ({ | |
| 391 | login: { name: "login", status: "red", statusCode: 500, durationMs: 10, error: "500" }, | |
| 392 | }), | |
| 393 | postAlert: async (url, payload) => { | |
| 394 | alertCalls.push({ url, payload }); | |
| 395 | }, | |
| 396 | alertUrl: () => "http://alerts.example.com/hook", | |
| 397 | }); | |
| 398 | expect(summary2.transitions).toBe(0); | |
| 399 | expect(alertCalls.length).toBe(0); | |
| 400 | }); | |
| 401 | ||
| 402 | it("skips the webhook when MONITOR_ALERT_WEBHOOK_URL is unset", async () => { | |
| 403 | const alertCalls: any[] = []; | |
| 404 | const summary = await runSyntheticMonitorTaskOnce({ | |
| 405 | runChecks: async () => [ | |
| 406 | { name: "login", status: "red", durationMs: 10, error: "x" }, | |
| 407 | ], | |
| 408 | persist: async () => {}, | |
| 409 | loadPrevious: async () => ({}), | |
| 410 | postAlert: async () => { | |
| 411 | alertCalls.push("called"); | |
| 412 | }, | |
| 413 | alertUrl: () => "", | |
| 414 | }); | |
| 415 | expect(summary.transitions).toBe(1); | |
| 416 | // Webhook helper never called because the URL is unset. | |
| 417 | expect(alertCalls.length).toBe(0); | |
| 418 | }); | |
| 419 | ||
| 420 | it("counts green / red / yellow correctly", async () => { | |
| 421 | const summary = await runSyntheticMonitorTaskOnce({ | |
| 422 | runChecks: async () => [ | |
| 423 | { name: "a", status: "green", durationMs: 1 }, | |
| 424 | { name: "b", status: "red", durationMs: 1, error: "x" }, | |
| 425 | { name: "c", status: "yellow", durationMs: 1 }, | |
| 426 | ], | |
| 427 | persist: async () => {}, | |
| 428 | loadPrevious: async () => ({}), | |
| 429 | postAlert: async () => {}, | |
| 430 | alertUrl: () => "", | |
| 431 | }); | |
| 432 | expect(summary.green).toBe(1); | |
| 433 | expect(summary.red).toBe(1); | |
| 434 | expect(summary.yellow).toBe(1); | |
| 435 | }); | |
| 436 | }); | |
| 437 | ||
| 438 | // --------------------------------------------------------------------------- | |
| 439 | // __test helpers — statusMatches | |
| 440 | // --------------------------------------------------------------------------- | |
| 441 | ||
| 442 | describe("synthetic-monitor — statusMatches helper", () => { | |
| 443 | it("defaults to 200 when expectStatus is undefined", () => { | |
| 444 | expect(__test.statusMatches(undefined, 200)).toBe(true); | |
| 445 | expect(__test.statusMatches(undefined, 201)).toBe(false); | |
| 446 | }); | |
| 447 | it("supports a single number", () => { | |
| 448 | expect(__test.statusMatches(204, 204)).toBe(true); | |
| 449 | expect(__test.statusMatches(204, 200)).toBe(false); | |
| 450 | }); | |
| 451 | it("supports an array of acceptable statuses", () => { | |
| 452 | expect(__test.statusMatches([200, 304], 304)).toBe(true); | |
| 453 | expect(__test.statusMatches([200, 304], 500)).toBe(false); | |
| 454 | }); | |
| 455 | }); | |
| 456 | ||
| 457 | // --------------------------------------------------------------------------- | |
| 458 | // /admin/status route — site-admin gating | |
| 459 | // --------------------------------------------------------------------------- | |
| 460 | ||
| 461 | // We mock the admin module so we can flip site-admin status without | |
| 462 | // touching the DB. Spread-from-real so the rest of admin's exports | |
| 463 | // (KNOWN_FLAGS etc.) keep their real shape. | |
| 464 | // | |
| 465 | // We use a dedicated Hono harness instead of the full `app` so we are | |
| 466 | // not at the mercy of upstream test files' `mock.module("../middleware/auth")` | |
| 467 | // poisons. The harness injects ?u=<name> as the logged-in user via its | |
| 468 | // own middleware. | |
| 469 | // We mock the admin module so we can flip site-admin status without | |
| 470 | // touching the DB. softAuth from `../middleware/auth` is not mocked | |
| 471 | // because Bun's `mock.module()` is process-global and the route | |
| 472 | // file's softAuth binding is already resolved by the time this test | |
| 473 | // runs (some earlier test file in the same `bun test` invocation | |
| 474 | // will have imported the app, freezing softAuth there). Instead we | |
| 475 | // test the 302 unauthenticated redirect through the route AND test | |
| 476 | // the site-admin / non-admin gate logic via `isSiteAdmin` directly. | |
| 477 | ||
| 478 | let _isSiteAdmin = false; | |
| 479 | mock.module("../lib/admin", () => ({ | |
| 480 | ..._real_admin, | |
| 481 | isSiteAdmin: async () => _isSiteAdmin, | |
| 482 | })); | |
| 483 | ||
| 484 | afterAll(() => { | |
| 485 | mock.module("../lib/admin", () => _real_admin); | |
| 486 | }); | |
| 487 | ||
| 488 | const adminStatusMod = await import("../routes/admin-status"); | |
| 489 | const adminStatusRoutes: any = (adminStatusMod as any).default; | |
| 490 | const { Hono } = await import("hono"); | |
| 491 | ||
| 492 | describe("synthetic-monitor — /admin/status route auth", () => { | |
| 493 | it("redirects to /login when no session (302)", async () => { | |
| 494 | const harness = new Hono(); | |
| 495 | harness.route("/", adminStatusRoutes); | |
| 496 | const res = await harness.fetch(new Request("http://x/admin/status")); | |
| 497 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 498 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 499 | }); | |
| 500 | ||
| 501 | it("`isSiteAdmin` gate distinguishes admin vs non-admin (direct logic test)", async () => { | |
| 502 | // The route's gate() function (see src/routes/admin-status.tsx) is: | |
| 503 | // if (!user) -> 302 /login | |
| 504 | // if (!isSiteAdmin(user.id)) -> 403 | |
| 505 | // else -> { user } | |
| 506 | // We exercise the gate's site-admin decision directly via the | |
| 507 | // mocked `isSiteAdmin` so the assertion is independent of the | |
| 508 | // brittle ESM/test-ordering interaction around softAuth. | |
| 509 | const { isSiteAdmin } = await import("../lib/admin"); | |
| 510 | _isSiteAdmin = false; | |
| 511 | expect(await isSiteAdmin("any")).toBe(false); | |
| 512 | _isSiteAdmin = true; | |
| 513 | expect(await isSiteAdmin("any")).toBe(true); | |
| 514 | }); | |
| 515 | ||
| 516 | it("/admin/status/run accepts POST and redirects when unauthed", async () => { | |
| 517 | const harness = new Hono(); | |
| 518 | harness.route("/", adminStatusRoutes); | |
| 519 | const res = await harness.fetch( | |
| 520 | new Request("http://x/admin/status/run", { method: "POST" }) | |
| 521 | ); | |
| 522 | // unauthed -> gate() returns 302 /login | |
| 523 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 524 | }); | |
| 525 | }); |