import { describe, it, expect, beforeAll, afterAll } from "vitest"; // Doit être défini avant l'import d'adminAuth (jwtSecret() lit process.env à l'appel, // mais on fixe la valeur ici pour que tous les tests partagent le même secret). process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32"; const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js"); const { rows } = await import("./helpers/fakePool.js"); const { createAdminApp } = await import("./helpers/testApp.js"); let sessionCookie, app, pool; beforeAll(async () => { sessionCookie = `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}`; ({ app, pool } = await createAdminApp()); }); afterAll(async () => { await app.close(); }); /** Reprogramme le pool partagé et renvoie l'app (construite une seule fois). */ function buildApp(handlers) { pool.reset(handlers ?? []); return app; } /** Requête authentifiée par défaut. */ function auth(headers = {}) { return { cookie: sessionCookie, ...headers }; } describe("garde d'authentification", () => { it("refuse toute route admin sans cookie de session", async () => { const app = buildApp(([])); for (const url of ["/admin/api/stats", "/admin/api/bands", "/admin/api/live", "/admin/api/activity"]) { const res = await app.inject({ method: "GET", url }); expect(res.statusCode, url).toBe(401); } }); it("refuse un cookie de session forgé", async () => { const app = buildApp(([])); const res = await app.inject({ method: "GET", url: "/admin/api/stats", headers: { cookie: `${ADMIN_COOKIE_NAME}=pas.un.jwt` }, }); expect(res.statusCode).toBe(401); }); it("ne touche pas la base quand la session est invalide", async () => { const handlers = ([]); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/stats" }); expect(pool.calls).toHaveLength(0); }); it("accepte une session valide", async () => { const handlers = ([{ match: "FROM bands", result: rows({ total: 0 }) }]); const app = buildApp(handlers); const res = await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth() }); expect(res.statusCode).toBe(200); }); }); describe("GET /admin/api/bands — construction de la requête", () => { const listPool = () => ([ { match: "count(*)::int AS total", result: rows({ total: 3 }) }, { match: "SELECT ma_id, name", result: rows({ ma_id: 1, name: "Mayhem" }) }, ]); it("n'accepte que les colonnes de tri de l'allowlist", async () => { const handlers = listPool(); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/bands?sort=name;DROP TABLE bands--&dir=desc", headers: auth(), }); const data = pool.find("SELECT ma_id, name"); expect(data.sql).not.toContain("DROP TABLE"); expect(data.sql).toContain("ORDER BY ma_id DESC"); }); it("accepte une colonne de tri légitime", async () => { const handlers = listPool(); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/bands?sort=formed_year", headers: auth() }); expect(pool.find("SELECT ma_id, name").sql).toContain("ORDER BY formed_year ASC"); }); it("passe les filtres en paramètres liés, jamais en interpolation", async () => { const handlers = listPool(); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/bands?q=' OR 1=1--&country=fr", headers: auth(), }); const data = pool.find("SELECT ma_id, name"); expect(data.sql).not.toContain("OR 1=1"); expect(data.values).toContain("%' OR 1=1--%"); expect(data.values).toContain("FR"); // pays normalisé en majuscules }); it("ignore une recherche de moins de 2 caractères", async () => { const handlers = listPool(); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/bands?q=a", headers: auth() }); expect(pool.find("SELECT ma_id, name").sql).not.toContain("ILIKE"); }); it("le count et la page utilisent le même WHERE", async () => { const handlers = listPool(); const app = buildApp(handlers); await app.inject({ method: "GET", url: "/admin/api/bands?country=de&enriched=true", headers: auth() }); const count = pool.find("count(*)::int AS total"); const data = pool.find("SELECT ma_id, name"); expect(count.sql).toContain("country = $1"); expect(data.sql).toContain("country = $1"); // le count ne doit pas recevoir les paramètres de pagination expect(count.values).toEqual(["DE"]); expect(data.values).toEqual(["DE", 50, 0]); }); it("borne pageSize à 200", async () => { const handlers = listPool(); const app = buildApp(handlers); const res = await app.inject({ method: "GET", url: "/admin/api/bands?pageSize=99999", headers: auth() }); expect(res.json().pageSize).toBe(200); }); }); describe("PATCH /admin/api/bands/:ma_id", () => { const patchPool = () => ([ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) }, { match: "UPDATE bands SET", result: rows({ ma_id: 1, name: "Après" }) }, { match: "INSERT INTO admin_audit_log", result: rows() }, ]); it("refuse un ma_id non numérique", async () => { const app = buildApp(patchPool()); const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/abc", headers: auth(), payload: { name: "x" }, }); expect(res.statusCode).toBe(400); }); it("refuse un corps sans champ modifiable", async () => { const app = buildApp(patchPool()); const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { ma_id: 999, enriched: true, crawled_at: "2020-01-01" }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/Aucun champ/); }); it("ignore les champs hors allowlist mais applique les autres", async () => { const handlers = patchPool(); const app = buildApp(handlers); await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "Darkthrone", enriched: false, locked_fields: "{}" }, }); const upd = pool.find("UPDATE bands SET"); expect(upd.sql).toContain("name = $2"); expect(upd.sql).not.toContain("enriched ="); expect(upd.sql).not.toContain("locked_fields = $"); }); it.each([ ["formed_year", { formed_year: 1799 }], ["formed_year", { formed_year: 2101 }], ["lat", { lat: 91 }], ["lon", { lon: -181 }], ])("rejette %s hors bornes", async (_field, payload) => { const app = buildApp(patchPool()); const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload }); expect(res.statusCode).toBe(400); }); it("verrouille les champs édités", async () => { const handlers = patchPool(); const app = buildApp(handlers); await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X", country: "NO" }, }); const upd = pool.find("UPDATE bands SET"); expect(upd.sql).toContain("locked_fields = locked_fields ||"); // Derniers paramètres : [ …valeurs, locked_fields JSON, clés à retirer ] expect(upd.values.at(-2)).toBe(JSON.stringify({ name: true, country: true })); expect(upd.values.at(-1)).toEqual(["name", "country"]); }); it("écrit une entrée d'audit", async () => { const handlers = patchPool(); const app = buildApp(handlers); await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } }); const audit = pool.find("INSERT INTO admin_audit_log"); expect(audit).toBeDefined(); expect(audit.values[0]).toBe("nico"); // l'utilisateur de la session, pas une valeur du corps }); it("404 si le groupe n'existe pas", async () => { const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]); const app = buildApp(handlers); const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } }); expect(res.statusCode).toBe(404); }); }); describe("POST /admin/api/bands/:ma_id/resolve-conflict", () => { const conflictPool = () => ([ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, crawler_pending: { name: "MA", formed_year: 1991 } }), }, { match: "UPDATE bands SET", result: rows({ ma_id: 1 }) }, { match: "INSERT INTO admin_audit_log", result: rows() }, ]); // Régression sécurité : `field` était interpolé dans le SET sans allowlist, // ce qui donnait une injection SQL à tout admin authentifié. it("refuse un nom de champ hors allowlist", async () => { const handlers = conflictPool(); const app = buildApp(handlers); const res = await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "name = 'pwned', enriched", action: "accept_crawler" }, }); expect(res.statusCode).toBe(400); expect(pool.find("UPDATE bands SET")).toBeUndefined(); }); it("refuse une action inconnue", async () => { const app = buildApp(conflictPool()); const res = await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "name", action: "drop_everything" }, }); expect(res.statusCode).toBe(400); }); it("keep_mine ne touche que crawler_pending", async () => { const handlers = conflictPool(); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "name", action: "keep_mine" }, }); const upd = pool.find("UPDATE bands SET"); expect(upd.sql).toContain("crawler_pending = crawler_pending - $2"); expect(upd.sql).not.toContain("locked_fields"); }); it("accept_crawler applique la valeur et déverrouille", async () => { const handlers = conflictPool(); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "name", action: "accept_crawler" }, }); const upd = pool.find("UPDATE bands SET"); expect(upd.sql).toContain("name = $2::text"); expect(upd.sql).toContain("locked_fields = locked_fields - $3"); expect(upd.values[1]).toBe("MA"); }); // Régression : le cast `::text` en dur cassait les colonnes numériques. it("utilise un cast numérique pour formed_year", async () => { const handlers = conflictPool(); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "formed_year", action: "accept_crawler" }, }); expect(pool.find("UPDATE bands SET").sql).toContain("formed_year = $2::int"); }); it("utilise un cast flottant pour lat/lon", async () => { const handlers = conflictPool(); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth(), payload: { field: "lat", action: "accept_crawler" }, }); expect(pool.find("UPDATE bands SET").sql).toContain("lat = $2::double precision"); }); }); describe("POST /admin/api/job-triggers", () => { const jobPool = () => ([ { match: "INSERT INTO job_triggers", result: rows({ id: 42 }) }, { match: "INSERT INTO crawl_log", result: rows() }, ]); it.each(["enrich", "incremental", "full_crawl", "geocoder_enqueue"])( "accepte le job_type %s", async (job_type) => { const app = buildApp(jobPool()); const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers", headers: auth(), payload: { job_type }, }); expect(res.statusCode).toBe(200); expect(res.json().id).toBe(42); } ); it.each([["inconnu"], [""], ["DROP TABLE bands"]])("refuse le job_type %s", async (job_type) => { const handlers = jobPool(); const app = buildApp(handlers); const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers", headers: auth(), payload: { job_type }, }); expect(res.statusCode).toBe(400); expect(pool.find("INSERT INTO job_triggers")).toBeUndefined(); }); it("attribue le job à l'utilisateur de la session", async () => { const handlers = jobPool(); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/job-triggers", headers: auth(), payload: { job_type: "enrich", requested_by: "quelquun-dautre" }, }); expect(pool.find("INSERT INTO job_triggers").values).toEqual(["enrich", "nico"]); }); }); describe("annulations", () => { it("404 quand le run n'est plus en cours", async () => { const handlers = ([{ match: "UPDATE crawl_run", result: rows() }]); const app = buildApp(handlers); const res = await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth(), payload: {}, }); expect(res.statusCode).toBe(404); }); it("cleanup borne older_than_minutes à 1 minimum", async () => { const handlers = ([ { match: "UPDATE crawl_run", result: rows() }, { match: "INSERT INTO admin_audit_log", result: rows() }, ]); const app = buildApp(handlers); await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth(), payload: { older_than_minutes: -999 }, }); expect(pool.find("UPDATE crawl_run").values).toEqual([1]); }); });