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("/api/clusters — troncature", () => { it("signale la troncature quand le plafond est atteint", async () => { // La réponse était plafonnée sans le dire : dans une zone dense, des // groupes disparaissaient de la carte sans aucun signal. const pleine = Array.from({ length: 2000 }, (_, i) => ({ ma_id: i })); const res = await build([{ match: "FROM band_locations", result: { rows: pleine, rowCount: 2000 } }]) .inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" }); const body = JSON.parse(res.payload); expect(body.truncated).toBe(true); expect(body.limit).toBe(2000); }); it("ne signale rien quand tout tient", async () => { const res = await build([{ match: "FROM band_locations", result: { rows: [{ ma_id: 1 }], rowCount: 1 } }]) .inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" }); expect(JSON.parse(res.payload).truncated).toBe(false); }); }); 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("annonce Vary: Origin, y compris quand l'origine est refusée", async () => { // L'en-tete Allow-Origin DEPEND de l'origine demandee. Sans Vary, un cache // intermediaire peut servir a une origine la reponse mise en cache pour une // autre — ce qui revient a autoriser une origine qui ne l'est pas, ou a // faire refuser une origine legitime. for (const origin of ["https://metalfrom.eu", "https://evil.example", undefined]) { const res = await build().inject({ method: "GET", url: "/api/health", headers: origin ? { origin } : {}, }); expect(res.headers["vary"]).toContain("Origin"); } }); 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: "FROM admin_login_attempts", result: rows({ par_ip: 99, par_username: 0 }) }, { 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: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 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: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 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: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 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(); }); });