metalfrom.eu/apps/api/test/adminAuth.test.js
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
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>
2026-08-18 10:05:40 +02:00

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"]);
});
});