Le dépôt n'avait aucun test, aucun linter, aucune vérification de types. Outillage - ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python - tsc --checkJs sur l'API (pas de TypeScript, juste la vérification) - Vitest : 401 tests JS ; pytest : 43 tests Python - Tests de mutation (Stryker), deux profils : logique pure et API complète - Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook, c'est donc la seule porte de qualité avant la mise en ligne - Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré) Sécurité - Injection SQL authentifiée dans resolve-conflict : `field` était interpolé dans le SET sans allowlist - timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401) - setErrorHandler écrasait tous les 4xx en 500 - .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part alors que leur absence casse toute connexion admin Annulation réelle des crawl_run (migration 014) - L'API posait status='error' sans que le crawler en sache rien : le process continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur) - Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler écrit lui-même status='cancelled' Cohérence géographique (migration 014) - Le trigger 013 supprimait les band_locations sans purger le point dénormalisé - L'édition admin de lat/lon n'atteignait jamais band_locations : la carte ignorait la correction. Override step_order = -1, dans une transaction Corrections - limit/offset NaN → 500 au lieu de 400 - OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie) - listen() sans catch, cast ::text en dur sur les colonnes numériques - /admin/api/logs ne renvoyait pas sa pagination - a11y : sélecteur de langue annoncé comme liste vide (role=option manquant) Nettoyage - apps/web/quizz-site supprimé (sans rapport avec le projet) - Code mort : openModal(), LANG_NAMES, double import, variables inutilisées - .dockerignore ajoutés ; node_modules racine n'était pas gitignoré Co-Authored-By: Claude <noreply@anthropic.com>
132 lines
5.4 KiB
JavaScript
132 lines
5.4 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,
|
|
} = 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"]);
|
|
});
|
|
});
|