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, seedAdminUser, recordLoginAttempt, markLoginSuccess, writeAuditLog, } = 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", () => { const compteurs = (par_ip, par_username) => makeFakePool([{ match: "admin_login_attempts", result: rows({ par_ip, par_username }) }]); it("verrouille à partir de 5 échecs venant de la même IP", async () => { expect(await isLockedOut(compteurs(4, 4), "nico", "1.2.3.4")).toBe(false); expect(await isLockedOut(compteurs(5, 5), "nico", "1.2.3.4")).toBe(true); }); it("un tiers ne peut pas verrouiller un compte en échouant depuis ailleurs", async () => { // Le coeur du correctif : les deux compteurs etaient additionnes dans un // seul total. Cinq echecs avec le nom d'un admin, depuis n'importe quelle // adresse, le mettaient dehors un quart d'heure — a repeter indefiniment. // L'admin legitime, lui, n'a aucun echec a son IP : il doit passer. expect(await isLockedOut(compteurs(0, 19), "nico", "1.2.3.4")).toBe(false); }); it("un seuil par username subsiste contre une attaque répartie", async () => { expect(await isLockedOut(compteurs(0, 20), "nico", "1.2.3.4")).toBe(true); }); it("compte sur une fenêtre de 15 minutes", async () => { const pool = compteurs(0, 0); await isLockedOut(pool, "nico", "1.2.3.4"); const call = pool.find("admin_login_attempts"); expect(call.sql).toMatch(/FILTER \(WHERE ip = \$3\)/); expect(call.sql).toMatch(/FILTER \(WHERE username = \$2\)/); expect(call.values).toEqual([15, "nico", "1.2.3.4"]); }); }); /** * Les quatre fonctions ci-dessous n'avaient AUCUN test direct — elles portaient * l'essentiel des 22 mutants sans couverture du fichier (score 52 %, le plus * bas du dépôt, sur le module qui gère JWT, bcrypt et le verrouillage). * * writeAuditLog est le cas le plus gênant : il peut cesser d'enregistrer sans * qu'aucune action admin n'échoue et sans qu'aucun test ne rougisse. Un journal * d'audit muet ne se remarque que le jour où on en a besoin. */ describe("écritures non couvertes jusqu'ici", () => { it("seedAdminUser ne fait rien sans identifiants", async () => { const pool = makeFakePool(); delete process.env.ADMIN_SEED_USERNAME; delete process.env.ADMIN_SEED_PASSWORD_HASH; await seedAdminUser(pool); expect(pool.calls).toHaveLength(0); }); it("seedAdminUser crée le compte sans écraser un existant", async () => { const pool = makeFakePool(); process.env.ADMIN_SEED_USERNAME = " nico "; process.env.ADMIN_SEED_PASSWORD_HASH = " $2b$12$hash "; await seedAdminUser(pool); const call = pool.find("INSERT INTO admin_users"); expect(call.values).toEqual(["nico", "$2b$12$hash"]); // Sans ON CONFLICT DO NOTHING, un redémarrage écraserait le mot de passe. expect(call.sql).toMatch(/ON CONFLICT \(username\) DO NOTHING/); delete process.env.ADMIN_SEED_USERNAME; delete process.env.ADMIN_SEED_PASSWORD_HASH; }); it("recordLoginAttempt enregistre l'issue réelle", async () => { const pool = makeFakePool(); await recordLoginAttempt(pool, "nico", "1.2.3.4", false); expect(pool.find("INSERT INTO admin_login_attempts").values).toEqual(["nico", "1.2.3.4", false]); await recordLoginAttempt(pool, "nico", "1.2.3.4", true); expect(pool.findAll("INSERT INTO admin_login_attempts")[1].values[2]).toBe(true); }); it("markLoginSuccess horodate le bon compte", async () => { const pool = makeFakePool(); await markLoginSuccess(pool, "nico"); const call = pool.find("UPDATE admin_users"); expect(call.values).toEqual(["nico"]); expect(call.sql).toMatch(/last_login_at = now\(\)/); }); it("writeAuditLog sérialise avant/après en JSON", async () => { const pool = makeFakePool(); await writeAuditLog(pool, "nico", "update", "bands", 42, { a: 1 }, { a: 2 }); const call = pool.find("INSERT INTO admin_audit_log"); expect(call.values[0]).toBe("nico"); expect(call.values[3]).toBe("42"); // target_id est du texte expect(JSON.parse(call.values[4])).toEqual({ a: 1 }); expect(JSON.parse(call.values[5])).toEqual({ a: 2 }); }); it("writeAuditLog accepte l'absence d'avant/après", async () => { const pool = makeFakePool(); await writeAuditLog(pool, "nico", "cleanup", "crawl_run", null, null, null); const call = pool.find("INSERT INTO admin_audit_log"); expect(call.values[4]).toBeNull(); expect(call.values[5]).toBeNull(); }); });