Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.
- Table AdminUser isolee (aucune relation vers UserProfile), migration
20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
/logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
+ tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
(AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
dans la reponse), /me derriere requireAdmin, logout, et un cookie
`session` d'utilisateur normal ne donne pas acces a /admin/*.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.8 KiB
TypeScript
149 lines
5.8 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/auth/login", () => {
|
|
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
|
|
const res = await request(app).post("/admin/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/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/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/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/auth/me", () => {
|
|
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).get("/admin/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/auth/login").send({ email, password });
|
|
|
|
const res = await agent.get("/admin/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/auth/login").send({ email, password });
|
|
|
|
const logoutRes = await agent.post("/admin/auth/logout");
|
|
expect(logoutRes.status).to.equal(204);
|
|
|
|
const meRes = await agent.get("/admin/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/auth/me");
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
});
|
|
});
|