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>
43 lines
1.9 KiB
TypeScript
43 lines
1.9 KiB
TypeScript
import { parseCookie } from "cookie";
|
|
import type { Socket } from "socket.io";
|
|
import { env } from "../config/env.js";
|
|
import { resolveAdminFromToken } from "./require-admin.js";
|
|
|
|
/**
|
|
* `socket.io` handshake middleware guarding the admin WebSocket surface —
|
|
* the connection-level equivalent of {@link requireAdmin} (`require-
|
|
* admin.ts`). A `socket.io` handshake is a plain HTTP request that never
|
|
* runs through Express's own middleware stack (`cookie-parser` included),
|
|
* so the raw `Cookie` header has to be parsed by hand here with the
|
|
* `cookie` package (the same one `cookie-parser` itself wraps).
|
|
*
|
|
* Registered via `io.use(requireAdminSocket)` — `socket.io` calls every
|
|
* `io.use` middleware once per connection attempt, before `"connection"`
|
|
* fires, exactly like Express's per-request middleware chain. Calling
|
|
* `next()` with no argument admits the connection; calling it with an
|
|
* `Error` rejects it, which the client sees as a `connect_error` event
|
|
* (`socket.io-client` never even fires `"connect"` for it).
|
|
*
|
|
* On success, stashes the resolved admin on `socket.data.adminUser` —
|
|
* `socket.io`'s per-connection storage bag, the WebSocket equivalent of
|
|
* `res.locals` — for any handler that ends up needing it later.
|
|
*/
|
|
export async function requireAdminSocket(
|
|
socket: Socket,
|
|
next: (err?: Error) => void,
|
|
): Promise<void> {
|
|
try {
|
|
const cookieHeader = socket.handshake.headers.cookie;
|
|
const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined;
|
|
if (typeof token !== "string") {
|
|
throw new Error("Not authenticated");
|
|
}
|
|
|
|
socket.data.adminUser = await resolveAdminFromToken(token);
|
|
next();
|
|
} catch {
|
|
// Covers everything resolveAdminFromToken can throw, plus a missing
|
|
// cookie — same "never distinguish the reason" posture as requireAdmin.
|
|
next(new Error("Not authenticated"));
|
|
}
|
|
}
|