adminAuth.js était le fichier le moins couvert du dépôt — 52 % de score de mutation, 22 mutants sans aucune couverture — alors qu'il porte JWT, bcrypt et le verrouillage de compte. seedAdminUser, recordLoginAttempt, markLoginSuccess et writeAuditLog n'avaient aucun test direct. writeAuditLog était le plus gênant : il peut cesser d'enregistrer sans qu'aucune action admin n'échoue et sans qu'aucun test ne rougisse, et un journal d'audit muet ne se remarque que le jour où on en a besoin. Mesuré après coup : adminAuth.js passe de 52,31 % à 84,29 %, les mutants sans couverture de 22 à 1, et le score global de 82,20 % à 88,85 %. Le job Python de la CI n'installait que ruff, pytest et pip-audit : psycopg2, beautifulsoup4, lxml et hypothesis manquaient, et il aurait échoué dès la collecte — ce que personne n'a jamais vu, faute de runner enregistré. Il part désormais de requirements-dev.txt, et lance aussi la suite du crawler, qui n'était pas exécutée du tout. requirements-dev.txt épinglait psycopg2-binary==2.9.9, sans roue pour Python >= 3.13 : la suite entière s'arrêtait dès la collecte sur un poste par ailleurs sain. Épinglages assouplis en bornes basses. Corrige enfin le commentaire de a11y.test.js, qui renvoyait à « l'audit Lighthouse manuel du README de la CI » pour justifier deux règles désactivées. Cet audit n'existe pas : README-CI.md documente le test e2e de l'admin, qui couvre bien le contraste mais UNIQUEMENT pour le dashboard. Pour le site public, ces règles ne sont vérifiées nulle part. La couverture n'a pas changé, seule sa description était fausse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
9 KiB
JavaScript
216 lines
9 KiB
JavaScript
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();
|
|
});
|
|
});
|