health.py existait en deux copies rigoureusement identiques — crawler et geocoder — dont une seule était couverte par des tests. Rien ne signalait une divergence : une correction appliquée d'un seul côté serait passée inaperçue. Le module part dans libs/bm_health.py, embarqué dans les images via un contexte de build ramené à la racine du dépôt, et rendu importable en local par les conftest.py. Un .dockerignore racine évite que node_modules parte dans les images au passage. Les deux images ont été reconstruites et le module vérifié importable à l'exécution dans chacune. La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de l'admin ne pouvait donc rien signaler — y compris quand le service était mort. Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec des sondes propres à leur rôle (clé API, progression de leur file). Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle. /admin/api/locations/reset-llm était pourtant le seul moyen de relancer les lieux passés en 'manual' après épuisement des tentatives LLM : la fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec /admin/api/live et disparaît, avec ses tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
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/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();
|
|
});
|
|
});
|