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>
174 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|