Le tableau de bord /admin/monitoring fonctionnait en polling côté client (15 s) déclenchant une sonde active côté serveur à chaque appel — deux onglets admin ouverts = deux séries de sondes redondantes. Inverse le mécanisme : le serveur sonde une fois par minute et pousse le résultat aux clients connectés via WebSocket (socket.io, room "monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne en continu dès le démarrage, sans lien avec la présence d'un admin connecté (le plus simple des deux, coût des sondes négligeable). Backend : - ExpressServer (packages/express-tools) expose désormais son http.Server sous-jacent (créé explicitement, plus dans .listen()) — socket.io s'attache au serveur HTTP lui-même, pas à Express/un port. - MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune duplication), diffuse à la room "monitoring", rejoue le dernier résultat en cache à tout nouvel admin qui rejoint (pas d'attente jusqu'à une minute pour un onglet fraîchement ouvert). - requireAdminSocket (nouveau) : middleware de handshake socket.io, équivalent WebSocket de requireAdmin — même vérification (cookie + JWT + tokenVersion), factorisée dans resolveAdminFromToken (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent le cookie brut (un handshake socket.io ne passe pas par cookie-parser). - GET /admin/api/monitoring supprimée (route + service de test associés). - server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont pas besoin d'un serveur WebSocket. Frontend : - MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper socket.io-client, interface MonitoringSocketClientLike volontairement petite et injectable dans MonitoringPage (prop optionnelle) pour rester testable sans serveur réel. - MonitoringPage : data et connectionState séparés — une coupure de connexion après un premier chargement affiche un badge "reconnexion en cours" sans jamais effacer le dernier board connu (repose sur la reconnexion automatique de socket.io-client). Seul le tout premier chargement affiche l'état plein-écran chargement/erreur. - adminApiClient.getMonitoring() supprimée (plus utilisée). Tests : - Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion socket.io-client contre un serveur réel démarré dans le test (port 0), authentification avec un admin seedé + cookie signé, vérifie la réception de monitoring:update et le rejet sans cookie admin valide. Les tests heartbeat de l'ancien admin-monitoring.test.ts (route /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont déplacés vers tech-step-worker.routes.test.ts, leur router d'origine. - Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un client factice, vérifie rendu des cartes, badge de reconnexion sans perte du board, états chargement/erreur — pas de test e2e Gherkin contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo, fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur). - apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé par le test composant ci-dessus. Vérification : pnpm lint clean, build complet du monorepo clean, suite Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable, comme pour #33) — le nouveau test socket.io n'a donc pas pu être run-vérifié, seulement relu attentivement et vérifié par le lint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
159 lines
6.5 KiB
TypeScript
159 lines
6.5 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;
|
|
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<void>((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<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);
|
|
});
|
|
});
|