import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Parser } from "node-sql-parser"; 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, createFullApp } = await import("./helpers/testApp.js"); /** * Ferme l'angle mort SQL des tests à faux pool. * * Le faux pool vérifie la FORME du SQL (allowlists, paramètres liés) mais ne * l'exécute jamais : une requête syntaxiquement invalide passait donc tous les * tests et n'échouait qu'en production. C'est exactement ce qui s'était produit * avec la reconstruction de crawler_pending. * * On parse ici chaque requête réellement émise avec la grammaire PostgreSQL. * Ce n'est pas une exécution — les noms de colonnes et la sémantique ne sont * pas validés — mais toute faute de syntaxe est attrapée sans conteneur. */ const parser = new Parser(); /** Requêtes que le parseur ne sait pas lire alors qu'elles sont valides. */ const UNSUPPORTED = [ // `x - ARRAY[...]` et `raw ? 'clé'` sont des opérateurs jsonb spécifiques // à Postgres que node-sql-parser ne modélise pas. /::jsonb\s*\)?\s*-\s*\$/, /\?\s*'place_rank'/, /jsonb_strip_nulls|jsonb_build_object/, // make_interval(mins => $1) : appel par nom d'argument /make_interval\(/, // bands[1:5] : découpage de tableau propre à Postgres /\[1:5\]/, // `UPDATE ... FROM (sous-requête) AS src` : syntaxe Postgres non modélisée. // Celle-ci EST validée par la suite d'intégration, contre un vrai serveur. /UPDATE bands b\s+SET/, // Requêtes de contrôle de transaction /^(BEGIN|COMMIT|ROLLBACK)$/, ]; function shouldParse(sql) { return !UNSUPPORTED.some((re) => re.test(sql.trim())); } /** * Le parseur ne connaît pas les paramètres liés `$1` : on les remplace par des * littéraux, ce qui préserve la structure de la requête. * * On utilise un NOMBRE et non une chaîne : `LIMIT $1` exige un littéral * numérique côté grammaire. Le parseur ne fait pas de contrôle de types, un * nombre passe donc aussi bien là où une chaîne serait attendue. */ function normalise(sql) { return sql .replace(/\$(\d+)::[a-z ]+\[\]/gi, "ARRAY[1]") .replace(/\$(\d+)::[a-z ]+/gi, "1") .replace(/\$\d+/g, "1"); } function assertParses(sql, label) { try { parser.astify(normalise(sql), { database: "postgresql" }); } catch (err) { throw new Error(`SQL invalide (${label}) : ${err.message}\n---\n${sql.trim()}\n---`); } } let auth, adminApp, adminPool, pubApp, pubPool; beforeAll(async () => { auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` }; ({ app: adminApp, pool: adminPool } = await createAdminApp()); ({ app: pubApp, pool: pubPool } = await createFullApp()); }); afterAll(async () => { await adminApp.close(); await pubApp.close(); }); /** * Rejoue une requête HTTP et parse tout le SQL qu'elle a produit. * * @param {import("fastify").FastifyInstance} app * @param {any} pool * @param {{ method?: string, url: string, payload?: any, handlers?: any[], unparsable?: boolean }} route */ async function checkRoute(app, pool, route) { const { method = "GET", url, payload, handlers = [], unparsable = false } = route; pool.reset([...handlers, { match: /./, result: rows({ n: 1, total: 1, id: 1, ma_id: 1, rowCount: 1 }) }]); const res = await app.inject( /** @type {any} */ ({ method, url, headers: auth, payload })); expect([200, 400, 404, 409]).toContain(res.statusCode); const parsed = pool.calls.filter((c) => shouldParse(c.sql)); if (!unparsable) { // Sans cette garde, une route dont TOUTES les requêtes tomberaient dans la // liste UNSUPPORTED passerait au vert sans rien avoir validé. expect(parsed.length, `aucune requête parsable pour ${method} ${url}`).toBeGreaterThan(0); } for (const call of parsed) assertParses(call.sql, `${method} ${url}`); return parsed.length; } describe("SQL des routes admin", () => { const ROUTES = [ { url: "/admin/api/stats" }, { url: "/admin/api/queue" }, { url: "/admin/api/bands" }, { url: "/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc&page=2" }, { url: "/admin/api/bands/1" }, { url: "/admin/api/crawl-checkpoints" }, { url: "/admin/api/logs" }, { url: "/admin/api/logs?level=error&run_id=1&min_id=5&since=2026-01-01" }, { url: "/admin/api/geocoding" }, { url: "/admin/api/llm" }, { url: "/admin/api/llm?model=x&only_null=1&q=oslo" }, { url: "/admin/api/job-triggers" }, { url: "/admin/api/live" }, // Angle mort assumé : ces deux requêtes sont bâties sur jsonb_build_object, // que node-sql-parser ne modélise pas. Leur syntaxe n'est donc PAS validée // ici — c'est le prix de ne pas monter de Postgres. { url: "/admin/api/activity", unparsable: true }, { url: "/admin/api/activity?type=run&status=done", unparsable: true }, { url: "/admin/api/locations" }, { url: "/admin/api/locations?status=error,llm_needed&q=oslo&country=NO&provider=geoapify" }, ]; it.each(ROUTES.map((r) => [r.url, r]))("%s produit du SQL syntaxiquement valide", async (_u, route) => { await checkRoute(adminApp, adminPool, route); }); const MUTATIONS = [ { method: "POST", url: "/admin/api/job-triggers", payload: { job_type: "enrich" } }, { method: "POST", url: "/admin/api/crawl-runs/1/cancel", payload: {} }, { method: "POST", url: "/admin/api/job-triggers/1/cancel", payload: {} }, { method: "POST", url: "/admin/api/locations/reset-errors", payload: {} }, { method: "POST", url: "/admin/api/locations/reset-llm", payload: {} }, { method: "POST", url: "/admin/api/locations/reset-all", payload: {} }, { method: "POST", url: "/admin/api/locations/requeue-all", payload: { include_done: true } }, { method: "POST", url: "/admin/api/locations/1/requeue", payload: {} }, { method: "PATCH", url: "/admin/api/locations/1", payload: { lat: 1, lon: 2 } }, ]; it.each(MUTATIONS.map((r) => [`${r.method} ${r.url}`, r]))( "%s produit du SQL syntaxiquement valide", async (_u, route) => { await checkRoute(adminApp, adminPool, route); } ); // Le PATCH construit son SET dynamiquement : chaque combinaison de champs // produit une requête différente, donc une occasion différente de se tromper. it.each([ ["un champ texte", { name: "X" }], ["un champ numérique", { formed_year: 1991 }], ["des coordonnées", { lat: 59.9, lon: 10.7 }], ["tous les champs", { name: "X", country: "NO", status: "Active", genre: "Black", formed_year: 1991, themes: "War", location_text: "Oslo", lat: 1, lon: 2, }], ])("PATCH /admin/api/bands avec %s", async (_label, payload) => { adminPool.reset([ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, lat: 1, lon: 2, location_text: "Oslo" }) }, { match: /./, result: rows({ ma_id: 1, lat: 1, lon: 2, location_text: "Oslo" }) }, ]); const res = await adminApp.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload, }); expect(res.statusCode).toBe(200); for (const call of adminPool.calls.filter((c) => shouldParse(c.sql))) { assertParses(call.sql, `PATCH bands (${Object.keys(payload).join(",")})`); } }); it.each(["name", "country", "formed_year", "lat"])( "resolve-conflict sur %s produit du SQL valide", async (field) => { adminPool.reset([ { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, crawler_pending: { [field]: "v" } }) }, { match: /./, result: rows({ ma_id: 1 }) }, ]); await adminApp.inject({ method: "POST", url: "/admin/api/bands/1/resolve-conflict", headers: auth, payload: { field, action: "accept_crawler" }, }); for (const call of adminPool.calls.filter((c) => shouldParse(c.sql))) { assertParses(call.sql, `resolve-conflict ${field}`); } } ); }); describe("SQL des routes publiques", () => { const ROUTES = [ "/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets", "/api/bands", "/api/bands?countries=NO,FR&status=Active&geocoded=1&only_black=1&q=mayhem&limit=10&offset=5", "/api/band/1", "/api/clusters?bbox=-5,40,10,55&zoom=5", "/api/clusters?bbox=-5,40,10,55&zoom=14", "/api/clusters?bbox=-5,40,10,55&zoom=5&countries=NO&status=Active&genre=black&year_min=1980&year_max=2000", ]; // Même réserve que pour /admin/api/activity : l'agrégation en grille des // clusters (zoom < 12) repose sur jsonb_build_object et array_agg, non // modélisés par le parseur. Le mode « points bruts » (zoom >= 12), lui, l'est. const UNVALIDATED = new Set([ "/api/clusters?bbox=-5,40,10,55&zoom=5", "/api/clusters?bbox=-5,40,10,55&zoom=5&countries=NO&status=Active&genre=black&year_min=1980&year_max=2000", ]); it.each(ROUTES)("%s produit du SQL syntaxiquement valide", async (url) => { pubPool.reset([{ match: /./, result: rows({ n: 1, total: 1, ma_id: 1 }) }]); const res = await pubApp.inject({ method: "GET", url }); expect(res.statusCode).toBe(200); const parsed = pubPool.calls.filter((c) => shouldParse(c.sql)); if (!UNVALIDATED.has(url)) expect(parsed.length).toBeGreaterThan(0); for (const call of parsed) assertParses(call.sql, url); }); it("/admin/import produit du SQL valide", async () => { const app = pubApp; pubPool.reset([{ match: /./, result: rows() }]); await app.inject({ method: "POST", url: "/admin/import", headers: { authorization: "Bearer " + (process.env.BM_IMPORT_TOKEN || "") }, payload: { bands: [{ ma_id: 1, name: "X" }] }, }); for (const call of pubPool.calls.filter((c) => shouldParse(c.sql))) { assertParses(call.sql, "/admin/import"); } }); }); describe("le détecteur lui-même", () => { // Sans ça, une régression du parseur (ou un normalise() trop permissif) // rendrait toute cette suite silencieusement inopérante. it("rejette une requête syntaxiquement invalide", () => { expect(() => assertParses("SELECT FROM WHERE ORDER", "témoin")).toThrow(/SQL invalide/); expect(() => assertParses("UPDATE bands SET WHERE id = 1", "témoin")).toThrow(/SQL invalide/); }); it("accepte une requête valide avec paramètres liés", () => { expect(() => assertParses( "SELECT a FROM bands WHERE id = $1 AND c = ANY($2::text[]) LIMIT $3 OFFSET $4", "témoin" )).not.toThrow(); }); });