metalfrom.eu/apps/api/test/adminRoutes.test.js
Nicolas Fryder c30656544c
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
fix(admin): passe de debug guidée par la mutation — 4 bugs, mutation 64 → 68 %
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.

Bugs trouvés et corrigés

1. crawler_pending détruit silencieusement (backend)
   Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
   seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
   supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
   ses conflits de genre, de pays et de localisation, sans trace.
   Corrigé en suivant la convention de /resolve-conflict : retrait des clés
   arbitrées (`crawler_pending - ARRAY[...]`).

2. Filtre genre non borné (backend)
   q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
   ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
   country et status sont bornés au passage, par cohérence.

3. Chips inutilisables après une alerte (frontend)
   Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
   alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
   revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
   (replaceState, qui ne déclenche pas hashchange).

4. Minuteur d'auto-refresh orphelin (frontend)
   Le minuteur était armé APRÈS le chargement des données. Quitter la vue
   pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
   Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
   autre onglet, indéfiniment. Résolu par un jeton de rendu.

Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.

Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.

Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js  : les 3 drapeaux × 2 polarités × présence/absence,
  bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js    : effet du PATCH sur crawler_pending, casts par type,
  allowlist, cohérence avec resolve-conflict
- tools.spec.js        : tri, pagination, arbitrage de conflit, annulations,
  filtres LLM, cycle de vie des vues

Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.

Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.

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

390 lines
14 KiB
JavaScript

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]);
});
});