import { describe, it, expect, vi } from "vitest"; process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32"; const { signAdminSession, verifyAdminSession, isLockedOut, verifyPassword, requireAdminSession, ADMIN_COOKIE_NAME, } = await import("../src/adminAuth.js"); const { makeFakePool, rows } = await import("./helpers/fakePool.js"); describe("sessions JWT", () => { it("aller-retour signature / vérification", () => { expect(verifyAdminSession(signAdminSession("nico"))).toBe("nico"); }); it("rejette un jeton signé avec un autre secret", async () => { const jwt = (await import("jsonwebtoken")).default; const forged = jwt.sign({ sub: "nico" }, "un-autre-secret-de-32-caracteres-ok"); expect(verifyAdminSession(forged)).toBeNull(); }); it("rejette un jeton `alg: none` (attaque classique)", async () => { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const payload = Buffer.from(JSON.stringify({ sub: "nico" })).toString("base64url"); expect(verifyAdminSession(`${header}.${payload}.`)).toBeNull(); }); it("rejette un jeton expiré", async () => { const jwt = (await import("jsonwebtoken")).default; const expired = jwt.sign({ sub: "nico" }, process.env.ADMIN_JWT_SECRET, { expiresIn: -10 }); expect(verifyAdminSession(expired)).toBeNull(); }); it("rejette un jeton sans sub exploitable", async () => { const jwt = (await import("jsonwebtoken")).default; const noSub = jwt.sign({ sub: { admin: true } }, process.env.ADMIN_JWT_SECRET); expect(verifyAdminSession(noSub)).toBeNull(); }); it("rejette une chaîne quelconque sans lever d'exception", () => { expect(verifyAdminSession("")).toBeNull(); expect(verifyAdminSession("aaa.bbb.ccc")).toBeNull(); }); }); describe("requireAdminSession", () => { function fakeReply() { const reply = { statusCode: null, body: null, code(c) { reply.statusCode = c; return reply; }, send(b) { reply.body = b; return reply; }, }; return reply; } it("renvoie le username et ne répond pas quand le cookie est valide", () => { const reply = fakeReply(); const req = { cookies: { [ADMIN_COOKIE_NAME]: signAdminSession("nico") } }; expect(requireAdminSession(req, reply)).toBe("nico"); expect(reply.statusCode).toBeNull(); }); it("répond 401 et renvoie null sans cookie", () => { const reply = fakeReply(); expect(requireAdminSession({ cookies: {} }, reply)).toBeNull(); expect(reply.statusCode).toBe(401); }); it("ne plante pas quand `cookies` est absent", () => { const reply = fakeReply(); expect(requireAdminSession({}, reply)).toBeNull(); expect(reply.statusCode).toBe(401); }); }); describe("verifyPassword", () => { it("renvoie false pour un utilisateur inexistant", async () => { const bcrypt = (await import("bcryptjs")).default; // Le DUMMY_HASH de production est à coût 12 (~330 ms par comparaison). // On neutralise le calcul : ce test porte sur la valeur de retour, et le // test suivant vérifie séparément que la comparaison a bien lieu. const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(true); const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]); // Même si bcrypt dit « vrai », l'absence d'utilisateur doit l'emporter. expect(await verifyPassword(pool, "inconnu", "peu-importe")).toBe(false); spy.mockRestore(); }); // Anti-énumération : le hash factice doit être comparé même quand l'utilisateur // n'existe pas, sinon le temps de réponse révèle les comptes valides. it("compare quand même un hash pour un utilisateur inexistant", async () => { const bcrypt = (await import("bcryptjs")).default; const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(false); const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]); await verifyPassword(pool, "inconnu", "x"); expect(spy).toHaveBeenCalled(); // La comparaison doit porter sur un hash bcrypt valide, sinon le temps de // réponse trahirait l'absence de compte. expect(spy.mock.calls[0][1]).toMatch(/^\$2[aby]\$\d\d\$/); spy.mockRestore(); }); it("renvoie true pour le bon mot de passe", async () => { const bcrypt = (await import("bcryptjs")).default; const hash = bcrypt.hashSync("bon-mot-de-passe", 4); // coût faible : test rapide const pool = makeFakePool([{ match: "FROM admin_users", result: rows({ password_hash: hash }) }]); expect(await verifyPassword(pool, "nico", "bon-mot-de-passe")).toBe(true); expect(await verifyPassword(pool, "nico", "mauvais")).toBe(false); }); }); describe("isLockedOut", () => { it("verrouille à partir de 5 tentatives échouées", async () => { const under = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 4 }) }]); const at = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 5 }) }]); expect(await isLockedOut(under, "nico", "1.2.3.4")).toBe(false); expect(await isLockedOut(at, "nico", "1.2.3.4")).toBe(true); }); it("compte les tentatives par username OU par IP, sur 15 minutes", async () => { const pool = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 0 }) }]); await isLockedOut(pool, "nico", "1.2.3.4"); const call = pool.find("admin_login_attempts"); expect(call.sql).toMatch(/username = \$2 OR ip = \$3/); expect(call.values).toEqual([15, "nico", "1.2.3.4"]); }); });