Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

synthetic-monitor.test.tsBlame525 lines · 1 contributor
b1be050CC LABS App1/**
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
18import {
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.
28const _real_db = await import("../db");
29const _real_admin = await import("../lib/admin");
30
31// ---------------------------------------------------------------------------
32// Fakes
33// ---------------------------------------------------------------------------
34
35const _inserted: { table: string; values: any }[] = [];
36let _latestRows: any[] = [];
37let _recentRedRows: any[] = [];
38
39const tableName = (t: any): string => {
40 if (!t) return "?";
41 if ("checkName" in t) return "synthetic_checks";
42 return "?";
43};
44
45const _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
70mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
71
72afterAll(() => {
73 mock.module("../db", () => _real_db);
74 _latestRows = [];
75 _recentRedRows = [];
76 _inserted.length = 0;
77});
78
79beforeEach(() => {
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`.
87const {
88 runSyntheticChecks,
89 persistChecks,
90 latestStatusByCheck,
91 SYNTHETIC_CHECKS,
92 SSE_TOPIC,
93 __test,
94} = await import("../lib/synthetic-monitor");
95const { runSyntheticMonitorTaskOnce } = await import("../lib/autopilot");
96const sseMod = await import("../lib/sse");
97
98// ---------------------------------------------------------------------------
99// Helpers
100// ---------------------------------------------------------------------------
101
102function jsonResp(body: unknown, status = 200): Response {
103 return new Response(JSON.stringify(body), {
104 status,
105 headers: { "content-type": "application/json" },
106 });
107}
108
109function textResp(body: string, status = 200): Response {
110 return new Response(body, {
111 status,
112 headers: { "content-type": "text/html" },
113 });
114}
115
116function 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
129describe("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
181describe("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
265describe("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
308describe("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
356describe("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
442describe("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
478let _isSiteAdmin = false;
479mock.module("../lib/admin", () => ({
480 ..._real_admin,
481 isSiteAdmin: async () => _isSiteAdmin,
482}));
483
484afterAll(() => {
485 mock.module("../lib/admin", () => _real_admin);
486});
487
488const adminStatusMod = await import("../routes/admin-status");
489const adminStatusRoutes: any = (adminStatusMod as any).default;
490const { Hono } = await import("hono");
491
492describe("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});