metalfrom.eu/apps/api/test/sqlSyntax.test.js
Nicolas Fryder 5237f3666d
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat: validation syntaxique du SQL + supervision des services de fond
Deux angles morts fermés.

1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js)
Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête
syntaxiquement invalide passait tous les tests et n'échouait qu'en production —
c'est précisément ce qui s'était produit avec crawler_pending.
Chaque requête réellement émise par les 28 routes est désormais parsée avec la
grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH
et les casts par type de resolve-conflict. 48 tests, aucun conteneur.
Limite déclarée explicitement : la sémantique n'est pas validée, et deux
requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue
si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il
passe au vert à vide.

2. Supervision des services de fond (migration 015)
Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP
ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un
worker à court de quota Geoapify, restait muet — le seul symptôme était
l'absence de données nouvelles, qu'il fallait remarquer soi-même.

Chaque service écrit un battement de cœur horaire dans service_health :
  - crawler  : base, FlareSolverr joignable, dernier run terminé < 12 h
  - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h
Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL
(> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier
contrôle réussi le ferait sinon passer pour sain indéfiniment.

Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou
silencieux en alerte actionnable.

Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle
surveille. Toute exception devient un échec de sonde, l'écriture du résultat et
la journalisation échouent en silence. Un contrôle de santé qui fait tomber le
crawler serait pire que pas de contrôle.

Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:20:15 +02:00

245 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\]/,
// 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();
});
});