batchCooking/apps/api/test/admin-auth.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

149 lines
5.9 KiB
TypeScript

import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
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";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
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 };
}
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin auth", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /admin/api/auth/login", () => {
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
const res = await request(app).post("/admin/api/auth/login").send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/admin/api/auth/login")
.send({ email: "nobody@example.com", password: "whatever" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const { email } = await seedAdmin();
const res = await request(app)
.post("/admin/api/auth/login")
.send({ email, password: "not-the-password" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const res = await request(app).post("/admin/api/auth/login").send({ email, password });
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
expect(res.body).to.not.have.property("passwordHash");
expect(res.body).to.not.have.property("tokenVersion");
expect(res.body.lastLoginAt).to.be.a("string");
const setCookie = res.headers["set-cookie"];
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
env.ADMIN_COOKIE_NAME,
);
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
expect(stored.lastLoginAt).to.not.equal(null);
});
});
describe("GET /admin/api/auth/me", () => {
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/admin/api/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the admin behind a valid admin session", 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/auth/me");
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
});
it("stops returning the admin after logout", 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 logoutRes = await agent.post("/admin/api/auth/logout");
expect(logoutRes.status).to.equal(204);
const meRes = await agent.get("/admin/api/auth/me");
expect(meRes.status).to.equal(401);
});
it("does not accept an end-user session cookie as an admin session", async () => {
// An ordinary user logs in (sets the `session` cookie), then tries the
// admin surface with that same agent — `requireAdmin` reads a
// different cookie entirely, so this must 401 regardless of whether
// ADMIN_JWT_SECRET is configured.
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/admin/api/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
});