Passe critique sur les zones non vérifiées. BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait band_locations sans jamais la déclencher : la correction apparaissait sur la carte — qui lit band_locations — mais jamais dans la liste, les statistiques ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit indéfiniment. La synchronisation est répliquée en SQL dans la même transaction, avec la même règle de tri (step_order ASC, id ASC) que le worker Python. BUG — course entre réplicas au démarrage (migrate.js) Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui faisait échouer le démarrage, au pire deux ALTER concurrents. Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js n'exécute plus au chargement s'il est importé (nécessaire pour le tester). CODE MORT — redis Signalé au tout début, jamais retiré : un conteneur redis + un volume persistant dans les DEUX composes, sans une seule référence dans le code. TESTS AJOUTÉS - Intégration migrations (7 tests) : application sur base vierge, rejouabilité, ordre lexicographique, relâchement du verrou, échec bruyant sur migration invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge — le cas qui motivait le verrou. - Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait. Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. L'invariant central : toute entrée arbitraire produit soit une valeur normalisée valide, soit une ValidationError — jamais une autre exception, jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi la cohérence offset = (page-1) × pageSize, le domaine des coordonnées, et qu'aucun caractère de contrôle ne survit à la validation. Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright Co-Authored-By: Claude <noreply@anthropic.com>
248 lines
10 KiB
JavaScript
248 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/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();
|
|
});
|
|
});
|