metalfrom.eu/apps/api/test/publicRoutes.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

430 lines
17 KiB
JavaScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
const { rows } = await import("./helpers/fakePool.js");
const { createFullApp, createFullAppWith } = await import("./helpers/testApp.js");
const TOKEN = "c4b99106fcd09badce25810e974ec4d6";
// Deux apps pour tout le fichier (cf. helpers/testApp.js : construire une app
// coûte ~14 ms, multipliées par des milliers d'exécutions en tests de mutation).
// Chaque cas se contente de reprogrammer le pool associé.
let app, pool, tokenApp, tokenPool;
beforeAll(async () => {
({ app, pool } = await createFullApp());
({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN }));
});
afterAll(async () => {
await app.close();
await tokenApp.close();
});
/** Handlers par défaut : chaque requête renvoie quelque chose de plausible. */
function defaultHandlers() {
return [
{ match: "FROM band_locations bl", result: rows({ ma_id: 1, name: "Mayhem", lat: 59.9, lon: 10.7 }) },
{ match: "grid_cells", result: rows({ lat: 59.9, lon: 10.7, count: 3, sample_bands: [] }) },
{ match: "FROM bands", result: rows({ ma_id: 1, name: "Mayhem", total: 1 }) },
];
}
/**
* Reprogramme le pool partagé et renvoie l'app publique.
* @param {import("./helpers/fakePool.js").Handler[]} [handlers]
*/
function build(handlers = defaultHandlers()) {
pool.reset(handlers);
return app;
}
/** Idem pour l'app configurée avec un jeton d'import. */
function buildToken(handlers = []) {
tokenPool.reset(handlers);
return tokenApp;
}
describe("/api/health et /", () => {
it("health répond 200 sans toucher la base", async () => {
const res = await build([]).inject({ method: "GET", url: "/api/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
expect(pool.calls).toHaveLength(0);
});
it("la racine identifie le service", async () => {
expect((await build([]).inject({ method: "GET", url: "/" })).json().ok).toBe(true);
});
});
describe("CORS", () => {
it("renvoie l'en-tête pour une origine autorisée", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu" },
});
expect(res.headers["access-control-allow-origin"]).toBe("https://metalfrom.eu");
});
it("n'écho PAS une origine non autorisée", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://evil.example" },
});
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});
it("ne fait pas de match par préfixe sur l'origine", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu.evil.example" },
});
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});
// Régression : sans `return reply`, Fastify poursuivait le cycle de vie et le
// préflight repartait dans le handler de route (ou un 404).
it("le préflight OPTIONS répond 204 et court-circuite le routage", async () => {
const res = await build().inject({ method: "OPTIONS", url: "/api/bands" });
expect(res.statusCode).toBe(204);
expect(res.body).toBe("");
});
it("OPTIONS répond 204 même sur une route inexistante", async () => {
expect((await build().inject({ method: "OPTIONS", url: "/nimporte/quoi" })).statusCode).toBe(204);
});
});
describe("en-têtes de sécurité (helmet)", () => {
it.each([
"x-content-type-options",
"x-frame-options",
"strict-transport-security",
])("pose %s", async (header) => {
const res = await build().inject({ method: "GET", url: "/api/health" });
expect(res.headers[header]).toBeDefined();
});
it("ne divulgue pas la stack ni les identifiants en cas d'erreur serveur", async () => {
const res = await build([
{ match: "FROM bands", throws: new Error("connexion perdue: host=10.0.1.7 user=bm") },
]).inject({ method: "GET", url: "/api/stats" });
expect(res.statusCode).toBe(500);
expect(res.body).not.toMatch(/10\.0\.1\.7|user=bm|at Object/);
});
});
describe("/api/bands — validation", () => {
it("répond 200 sans paramètre", async () => {
const res = await build().inject({ method: "GET", url: "/api/bands" });
expect(res.statusCode).toBe(200);
expect(res.json().limit).toBe(1000);
});
// Régression : Number("abc") = NaN partait dans LIMIT $n → erreur pg → 500.
it.each([
["limit=abc", "/api/bands?limit=abc"],
["limit=0", "/api/bands?limit=0"],
["limit=-5", "/api/bands?limit=-5"],
["offset=abc", "/api/bands?offset=abc"],
["offset=-1", "/api/bands?offset=-1"],
["offset>1M", "/api/bands?offset=1000001"],
])("répond 400 (pas 500) pour %s", async (_label, url) => {
const res = await build().inject({ method: "GET", url });
expect(res.statusCode).toBe(400);
expect(res.json().ok).toBe(false);
});
it("plafonne le limit sans erreur", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?limit=999999" })).json().limit).toBe(150000);
});
it("rejette une recherche d'un seul caractère", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?q=a" })).statusCode).toBe(400);
});
it("rejette les patterns ILIKE pathologiques", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?q=%25%25%25" })).statusCode).toBe(400);
});
it("rejette plus de 100 pays", async () => {
const many = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
const res = await build().inject({ method: "GET", url: `/api/bands?countries=${many}` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/Max 100/);
});
it("rejette plus de 50 statuts", async () => {
const many = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
expect((await build().inject({ method: "GET", url: `/api/bands?status=${many}` })).statusCode).toBe(400);
});
it("passe la recherche en paramètre lié", async () => {
await build().inject({
method: "GET", url: "/api/bands?q=" + encodeURIComponent("'; DROP TABLE bands--"),
});
const call = pool.find("FROM bands");
expect(call.sql).not.toContain("DROP TABLE");
expect(call.values.some((v) => String(v).includes("DROP TABLE"))).toBe(true);
});
it("normalise les codes pays en majuscules", async () => {
await build().inject({ method: "GET", url: "/api/bands?countries=fr,de" });
expect(pool.find("FROM bands").values).toContainEqual(["FR", "DE"]);
});
});
describe("/api/clusters — validation géographique", () => {
it.each([
["bbox absente", "/api/clusters?zoom=5"],
["zoom absent", "/api/clusters?bbox=-5,40,10,55"],
["bbox à 3 valeurs", "/api/clusters?bbox=1,2,3&zoom=5"],
["bbox non numérique", "/api/clusters?bbox=a,b,c,d&zoom=5"],
["latitude > 90", "/api/clusters?bbox=-5,40,10,95&zoom=5"],
["longitude < -180", "/api/clusters?bbox=-181,40,10,55&zoom=5"],
["bbox inversée", "/api/clusters?bbox=10,40,-5,55&zoom=5"],
["zoom négatif", "/api/clusters?bbox=-5,40,10,55&zoom=-1"],
["zoom > 22", "/api/clusters?bbox=-5,40,10,55&zoom=23"],
])("répond 400 pour %s", async (_label, url) => {
expect((await build().inject({ method: "GET", url })).statusCode).toBe(400);
});
it("accepte une bbox valide", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
expect(res.statusCode).toBe(200);
});
// Le seuil de zoom 12 fait basculer entre agrégation en grille et points bruts.
it("agrège en clusters en dessous du zoom 12", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" });
expect(res.json().type).toBe("clusters");
expect(pool.find("grid_cells")).toBeDefined();
});
it("renvoie les groupes bruts à partir du zoom 12", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=12" });
expect(res.json().type).toBe("bands");
expect(pool.find("grid_cells")).toBeUndefined();
});
it("ne lit que les localisations réellement géocodées", async () => {
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
const sql = pool.find("grid_cells").sql;
expect(sql).toContain("bl.geom IS NOT NULL");
expect(sql).toContain("geocode_status IN ('done','country_only')");
});
it("utilise l'index spatial via ST_MakeEnvelope avec des paramètres liés", async () => {
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
const call = pool.find("grid_cells");
expect(call.sql).toContain("bl.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)");
expect(call.values.slice(0, 4)).toEqual([-5, 40, 10, 55]);
});
});
describe("/api/band/:ma_id", () => {
it("répond 400 pour un identifiant non numérique", async () => {
expect((await build().inject({ method: "GET", url: "/api/band/abc" })).statusCode).toBe(400);
});
it("répond 404 quand le groupe n'existe pas", async () => {
const res = await build([{ match: "FROM bands", result: rows() }])
.inject({ method: "GET", url: "/api/band/42" });
expect(res.statusCode).toBe(404);
});
it("accepte un ma_id au-delà de 2^31 (colonne BIGINT)", async () => {
const res = await build([{ match: "FROM bands", result: rows({ ma_id: 3500000000 }) }])
.inject({ method: "GET", url: "/api/band/3500000000" });
expect(res.statusCode).toBe(200);
expect(pool.find("FROM bands").values).toEqual([3500000000]);
});
});
describe("/admin/import — jeton Bearer", () => {
it.each([
["sans en-tête", undefined],
["jeton vide", "Bearer "],
["jeton faux de même longueur", `Bearer ${"0".repeat(TOKEN.length)}`],
["jeton faux plus court", "Bearer abc"],
["schéma Basic", "Basic " + Buffer.from("a:b").toString("base64")],
])("répond 401 %s", async (_label, authorization) => {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: authorization ? { authorization } : {},
payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
});
it("ne touche pas la base quand le jeton est invalide", async () => {
await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: "Bearer faux" },
payload: { bands: [{ ma_id: 1 }] },
});
expect(tokenPool.calls).toHaveLength(0);
});
it("accepte le bon jeton", async () => {
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` },
payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
});
expect(res.statusCode).toBe(200);
expect(res.json().upserted).toBe(1);
});
// Un jeton multi-octets de même longueur en caractères mais pas en octets
// faisait lever timingSafeEqual → 500 au lieu d'un 401 propre.
it("répond 401 (pas 500) pour un jeton multi-octets", async () => {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${"é".repeat(TOKEN.length)}` },
payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
});
it("refuse un corps sans tableau bands", async () => {
for (const payload of [{}, { bands: "pas-un-tableau" }, { bands: null }]) {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` }, payload,
});
expect(res.statusCode).toBe(400);
}
});
it("refuse plus de 1000 groupes par lot", async () => {
const bands = Array.from({ length: 1001 }, (_, i) => ({ ma_id: i }));
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands },
});
expect(res.statusCode).toBe(400);
});
it("ignore les entrées sans ma_id numérique sans planter", async () => {
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` },
payload: { bands: [{ ma_id: "abc" }, null, "chaine", { ma_id: 7 }] },
});
expect(res.statusCode).toBe(200);
expect(res.json().upserted).toBe(1);
});
// Cas nécessitant une fabrique différente : app sans jeton configuré.
it("quand aucun jeton n'est configuré, tout est refusé", async () => {
const noTokenApp = await createFullAppWith({ importToken: "", pool: null });
const res = await noTokenApp.inject({
method: "POST", url: "/admin/import",
headers: { authorization: "Bearer nimportequoi" }, payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
await noTokenApp.close();
});
});
describe("/admin/auth/login", () => {
it("refuse un corps mal formé sans requête SQL", async () => {
const a = build([]);
for (const payload of [{}, { username: "a" }, { username: 1, password: 2 }]) {
expect((await a.inject({ method: "POST", url: "/admin/auth/login", payload })).statusCode).toBe(400);
}
expect(pool.calls).toHaveLength(0);
});
it("répond 429 quand le compte est verrouillé, sans vérifier le mot de passe", async () => {
const res = await build([
{ match: "admin_login_attempts", result: rows({ n: 99 }) },
{ match: "FROM admin_users", result: rows({ password_hash: "x" }) },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "x" },
});
expect(res.statusCode).toBe(429);
expect(pool.find("FROM admin_users")).toBeUndefined();
});
it("enregistre la tentative échouée et ne pose pas de cookie", async () => {
const bcrypt = (await import("bcryptjs")).default;
// Hash à coût 4 : le DUMMY_HASH de production est à coût 12 (~330 ms).
const hash = bcrypt.hashSync("autre-chose", 4);
const res = await build([
{ match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "mauvais" },
});
expect(res.statusCode).toBe(401);
expect(res.headers["set-cookie"]).toBeUndefined();
expect(pool.find("INSERT INTO admin_login_attempts").values[2]).toBe(false);
});
it("le cookie de session est httpOnly, secure, sameSite=strict", async () => {
const bcrypt = (await import("bcryptjs")).default;
const hash = bcrypt.hashSync("bon", 4);
const res = await build([
{ match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
{ match: "UPDATE admin_users", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "bon" },
});
expect(res.statusCode).toBe(200);
const cookie = String(res.headers["set-cookie"]);
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
expect(cookie).toMatch(/SameSite=Strict/i);
// Le jeton ne doit jamais apparaître dans le corps de la réponse
expect(res.json()).toEqual({ ok: true, username: "nico" });
});
it("tronque un username exagérément long avant la requête", async () => {
const bcrypt = (await import("bcryptjs")).default;
const hash = bcrypt.hashSync("x", 4);
await build([
{ match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "a".repeat(5000), password: "x" },
});
expect(pool.find("FROM admin_users").values[0]).toHaveLength(100);
});
});
describe("gestionnaire d'erreurs global", () => {
// Régression : setErrorHandler écrasait TOUS les statuts en 500, y compris
// les 4xx légitimes (payload trop gros, JSON invalide, rate-limit).
it("préserve un 400 sur JSON invalide", async () => {
const res = await build([]).inject({
method: "POST", url: "/admin/auth/login",
headers: { "content-type": "application/json" },
payload: "{ceci n'est pas du json",
});
expect(res.statusCode).toBe(400);
});
it("renvoie 500 générique pour une erreur inattendue", async () => {
const res = await build([{ match: "FROM bands", throws: new Error("boom") }])
.inject({ method: "GET", url: "/api/stats" });
expect(res.statusCode).toBe(500);
expect(res.json().error).not.toContain("boom");
});
});
describe("sans base de données configurée", () => {
it("les routes de données répondent 500 mais le service reste debout", async () => {
const noDbApp = await createFullAppWith({ pool: null });
expect((await noDbApp.inject({ method: "GET", url: "/api/stats" })).statusCode).toBe(500);
expect((await noDbApp.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
await noDbApp.close();
});
});