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>
430 lines
17 KiB
JavaScript
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();
|
|
});
|
|
});
|