batchCooking/apps/api/test/admin-monitoring.test.ts
Nicolas ba131bd8c8
All checks were successful
CI / build (push) Successful in 2m53s
CI / lint (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 5m6s
CI / e2e (push) Successful in 8m17s
CI / test (push) Successful in 15m19s
fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front
Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de
l'API au lieu de la page — même chose à craindre pour tout futur /admin/*
GET sans segment supplémentaire.

Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait
l'API admin exactement sous /admin, la même racine que les pages
/admin/* d'apps/web, servies depuis la même origine/le même process en
prod (image Docker unique). Express matche par chemin, sans égard au
Accept — `GET /admin/monitoring` (route API) et la page du même nom
étaient en conflit direct ; l'API gagnait systématiquement (montée
avant serveStaticFrontend), donc chaque F5 sur cette page recevait le
JSON de l'API plutôt que index.html.

Seule /admin/monitoring collidait réellement aujourd'hui (seul
sous-routeur admin à répondre GET sur sa racine bare) mais le
mécanisme touche toute future route admin nommée comme sa page — d'où
un correctif structurel plutôt qu'un renommage ponctuel.

Correctif : l'API admin est montée sous /admin/api (app.ts), jamais
bare /admin. Toute la surface admin bouge en conséquence
(auth/metrics/monitoring/tech-steps/catalog) ; les pages front
(/admin/monitoring, /admin/catalogue, /admin/corrections...) sont
inchangées, seul admin-client.ts change de base URL. Mis à jour en
cohérence : commentaires JSDoc citant les routes (apps/api + apps/web +
packages/shared), specs/backend-architecture.md,
specs/batch-cooking-modele.md, les intercepts Cypress (globs + un
matcher regex qui avait échappé au premier passage) et les chemins
supertest du suite Mocha admin-*.test.ts.

Simplifie au passage admin-monitoring.cy.ts : le contournement
resourceType ajouté en #32 pour cette même collision n'a plus lieu
d'être une fois les chemins distincts (retour à un intercept déclaratif
simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en
commentaire pour référence future.

Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète
28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*).
Suite Mocha apps/api non exécutable cette session (Postgres/Docker
injoignable) — chemins mis à jour mécaniquement en cohérence avec le
nouveau mount point, à faire tourner en CI/à la prochaine session avec
accès DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:48:07 +02:00

160 lines
6 KiB
TypeScript

import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
const SECRET_HEADER = "X-Internal-Worker-Secret";
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined;
describe("Admin monitoring", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /internal/tech-steps/heartbeat", () => {
it("rejects a request with no worker secret with 401", async () => {
const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" });
expect(res.status).to.equal(401);
});
it("rejects a malformed body with 400", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "not-a-real-event" });
expect(res.status).to.equal(400);
});
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({
event: "job",
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ ok: true });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastRunAt).to.not.equal(null);
expect(stored.lastResult).to.deep.equal({
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
});
it("leaves lastRunAt null for a boot/tick ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "boot" });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastSeenAt).to.be.instanceOf(Date);
expect(stored.lastRunAt).to.equal(null);
});
});
describe("GET /admin/api/monitoring", () => {
it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/api/monitoring");
expect(res.status).to.equal(401);
});
it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
const res = await agent.get("/admin/api/monitoring");
expect(res.status).to.equal(200);
const keys = res.body.services.map((s: { key: string }) => s.key);
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
for (const service of res.body.services) {
expect(VALID_STATUSES).to.include(service.status);
}
const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s]));
// The DB is up during the test run, and the API is answering us.
expect(byKey.postgres.status).to.equal("up");
expect(byKey.api.status).to.equal("up");
// No heartbeat has ever been recorded (resetDatabase truncated it).
expect(byKey["tech-step-llm-worker"].status).to.equal("unknown");
});
it("reports the worker as up once it has sent a recent heartbeat", async function () {
if (!adminSecretConfigured || !workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } });
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
const res = await agent.get("/admin/api/monitoring");
const worker = res.body.services.find(
(s: { key: string }) => s.key === "tech-step-llm-worker",
);
expect(worker.status).to.equal("up");
expect(worker.lastResult.job).to.equal("transform-corrections");
expect(worker.lastRunAt).to.be.a("string");
});
});
});