batchCooking/apps/api/test/admin-monitoring-socket.test.ts
Nicolas 23be1e4d04
All checks were successful
CI / build (push) Successful in 2m18s
CI / lint (push) Successful in 2m21s
CI / intent-service-test (push) Successful in 3m55s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m30s
fix(admin): ferme le serveur socket.io en fin de suite de test monitoring
Le hook `after` de admin-monitoring-socket.test.ts appelait seulement
`broadcast.stop()` (qui ne fait que couper l'intervalle de sonde) et
`prisma.$disconnect()`, mais ne fermait jamais le serveur socket.io ni le
`http.Server` en écoute sur le port 0. socket.io garde alors vivants les
timers ping d'engine.io et la socket d'écoute ; comme Mocha tourne sans
`--exit` (.mocharc.json), ce seul handle en fuite empêche le process de se
terminer une fois le dernier test passé.

C'est ce qui bloquait le job `test` en CI : « N passing » affiché, puis
plusieurs heures d'attente jusqu'au kill du runner (runs #80/#81).

Hisse `io` au scope de la suite et le ferme dans `after` via `io.close()`
— ce qui déconnecte les sockets restantes, stoppe les timers d'engine.io
et ferme le `http.Server` attaché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 19:56:45 +02:00

174 lines
7.4 KiB
TypeScript

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<T>(socket: ClientSocket, event: string, timeoutMs = 1500): Promise<T> {
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;
// Hoisted to suite scope (not a local in `before`) so `after` can shut
// it down. `broadcast.stop()` alone only clears the probe interval — the
// socket.io server keeps engine.io's ping timers and the listening
// http.Server (port 0) alive, and Mocha runs without `--exit`
// (`.mocharc.json`), so that one leaked handle stops the whole process
// from ever draining once the last test passes. That is exactly what
// stalled CI: the `test` job printed "N passing" and then hung for
// hours until the runner was killed.
let io: SocketIoServer;
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;
io = new SocketIoServer(httpServer, { cors: { origin: "*" } });
broadcast = new MonitoringBroadcastService(io);
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const { port } = httpServer.address() as AddressInfo;
baseUrl = `http://localhost:${port}`;
broadcast.start();
});
after(async () => {
broadcast.stop();
// Disconnects any socket still open, stops engine.io's timers, and
// closes the http.Server socket.io was attached to — without this the
// suite never lets Node's event loop drain (see the `io` field's note).
await new Promise<void>((resolve, reject) => {
io.close((err) => (err ? reject(err) : resolve()));
});
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<Error>(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<Error>(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<MonitoringView>(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<MonitoringView>(first, "monitoring:update", 6000),
waitForEvent<MonitoringView>(second, "monitoring:update", 6000),
]);
expect(firstSnapshot.generatedAt).to.equal(secondSnapshot.generatedAt);
});
});