import type { AddressInfo } from "node:net"; import type { MonitoringView } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; import { Server as SocketIoServer } from "socket.io"; import { type Socket as ClientSocket, io as ioClient } from "socket.io-client"; import { createServer } from "../src/app.js"; import { env } from "../src/config/env.js"; import { prisma } from "../src/db/prisma.js"; import { signAdminToken } from "../src/lib/admin-jwt.js"; import { MonitoringBroadcastService } from "../src/lib/monitoring-broadcast.service.js"; import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; import { resetDatabase } from "../test-support/reset-db.js"; const VALID_STATUSES = ["up", "degraded", "down", "unknown"]; const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; async function seedAdmin(): Promise<{ adminUserId: number; tokenVersion: number }> { const admin = await prisma.adminUser.create({ data: { email: faker.internet.email().toLowerCase(), name: faker.person.fullName(), passwordHash: await hashAdminPassword(faker.internet.password({ length: 16 })), }, }); return { adminUserId: admin.id, tokenVersion: admin.tokenVersion }; } /** * Waits for `event` on `socket`, rejecting after `timeoutMs` (default * tuned for the auth-rejection tests, which never touch a real probe and * so resolve almost immediately — the probe-touching tests below pass a * larger value explicitly) — every real WebSocket round-trip in this file * goes through this instead of a bare `socket.once`, so a broken push * fails the test with a clear message instead of hanging until Mocha's * own default test timeout. */ function waitForEvent(socket: ClientSocket, event: string, timeoutMs = 1500): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error(`Timed out waiting for "${event}"`)), timeoutMs, ); socket.once(event, (payload: T) => { clearTimeout(timer); resolve(payload); }); }); } describe("Admin monitoring — WebSocket push", () => { let baseUrl: string; let broadcast: MonitoringBroadcastService; const clientSockets: ClientSocket[] = []; // A real listening server, not just createApp()'s bare Express instance // (supertest's usual approach elsewhere in this suite) — socket.io needs // an actual http.Server to attach to, and a real client (socket.io- // client) needs a real port to connect to. Port 0 lets the OS pick a // free one, so this suite never collides with anything else listening. before(async () => { const httpServer = createServer().httpServer; const io = new SocketIoServer(httpServer, { cors: { origin: "*" } }); broadcast = new MonitoringBroadcastService(io); await new Promise((resolve) => httpServer.listen(0, resolve)); const { port } = httpServer.address() as AddressInfo; baseUrl = `http://localhost:${port}`; broadcast.start(); }); after(async () => { broadcast.stop(); await prisma.$disconnect(); }); beforeEach(async () => { await resetDatabase(); }); afterEach(() => { for (const socket of clientSockets.splice(0)) { socket.disconnect(); } }); /** Connects a client with the given cookie header (or none), tracked for automatic teardown in `afterEach`. */ function connect(cookie?: string): ClientSocket { const socket = ioClient(baseUrl, { extraHeaders: cookie ? { Cookie: cookie } : undefined, // This suite creates several short-lived connections in a row — // socket.io-client's default reconnection would otherwise keep // retrying a deliberately-rejected connection in the background // after its own test has already moved on. reconnection: false, forceNew: true, }); clientSockets.push(socket); return socket; } it("rejects a connection with no admin cookie at all", async () => { const socket = connect(); const err = await waitForEvent(socket, "connect_error"); expect(err.message).to.equal("Not authenticated"); }); it("rejects a connection carrying an end-user session cookie, not an admin one", async () => { const socket = connect("session=whatever-not-an-admin-token"); const err = await waitForEvent(socket, "connect_error"); expect(err.message).to.equal("Not authenticated"); }); it("accepts a valid admin session and pushes a monitoring:update covering all four targets", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } // Longer than Mocha's 2000ms default: the very first probe tick can // take close to PROBE_TIMEOUT_MS (2s) if e.g. the intent-service isn't // reachable from this test environment (a probe timing out is a valid, // expected outcome here — this test doesn't assert *which* status // comes back, only that the shape is right). this.timeout(8000); const { adminUserId, tokenVersion } = await seedAdmin(); const token = signAdminToken({ adminUserId, tokenVersion }); const socket = connect(`${env.ADMIN_COOKIE_NAME}=${token}`); const snapshot = await waitForEvent(socket, "monitoring:update", 6000); expect(typeof snapshot.generatedAt).to.equal("string"); const keys = snapshot.services.map((service) => service.key); expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]); for (const service of snapshot.services) { expect(VALID_STATUSES).to.include(service.status); } }); it("broadcasts the same update to every connected admin, not one probe per socket", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: see above. (this as any).skip(); return; } this.timeout(8000); // see the previous test's comment const { adminUserId, tokenVersion } = await seedAdmin(); const token = signAdminToken({ adminUserId, tokenVersion }); const cookie = `${env.ADMIN_COOKIE_NAME}=${token}`; const first = connect(cookie); const second = connect(cookie); const [firstSnapshot, secondSnapshot] = await Promise.all([ waitForEvent(first, "monitoring:update", 6000), waitForEvent(second, "monitoring:update", 6000), ]); expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt); }); });