metalfrom.eu/apps/api/test/publicRoutesCoverage.test.js
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.

Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
  c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)

Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
  dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
  alors que leur absence casse toute connexion admin

Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
  continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
  écrit lui-même status='cancelled'

Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
  ignorait la correction. Override step_order = -1, dans une transaction

Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)

Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 10:05:40 +02:00

393 lines
16 KiB
JavaScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
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 { createFullApp } = await import("./helpers/testApp.js");
const TOKEN_FOR_APP = "jeton-de-test";
// Deux apps seulement pour tout le fichier : une par jeu d'options de fabrique.
// Chaque test se contente de reprogrammer le pool associé.
let app, pool, tokenApp, tokenPool;
beforeAll(async () => {
({ app, pool } = await createFullApp());
({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN_FOR_APP }));
});
afterAll(async () => { await app.close(); await tokenApp.close(); });
/** Reprogramme le pool partagé et renvoie l'app publique. */
function build(handlers) {
pool.reset(handlers ?? []);
return app;
}
/** Idem pour l'app configurée avec un jeton d'import. */
function buildToken(handlers) {
tokenPool.reset(handlers ?? []);
return tokenApp;
}
const anyPool = (extra = []) =>
[...extra, { match: /./, result: rows({ n: 1, total: 1, value: "x", count: 1 }) }];
describe("routes d'agrégation publiques", () => {
const ROUTES = ["/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets"];
it.each(ROUTES)("%s répond 200 avec ok:true", async (url) => {
const app = build(anyPool());
const res = await app.inject({ method: "GET", url });
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
});
it.each(ROUTES)("%s répond 500 sans divulguer l'erreur si la base tombe", async (url) => {
const handlers = ([{ match: /./, throws: new Error("FATAL: role bm does not exist") }]);
const app = build(handlers);
const res = await app.inject({ method: "GET", url });
expect(res.statusCode).toBe(500);
expect(res.body).not.toMatch(/role bm|FATAL/);
});
it.each(ROUTES)("%s est accessible sans authentification (données publiques)", async (url) => {
const app = build(anyPool());
expect((await app.inject({ method: "GET", url })).statusCode).not.toBe(401);
});
});
describe("/api/stats", () => {
it("expose les compteurs attendus par le frontend", async () => {
const handlers = ([
{ match: "FROM bands", result: rows({ total: 100, geocoded: 80, no_location: 20, enriched: 60 }) },
]);
const app = build(handlers);
const body = (await app.inject({ method: "GET", url: "/api/stats" })).json();
expect(body).toMatchObject({ ok: true, total: 100, geocoded: 80, no_location: 20, enriched: 60 });
});
// geocoded compte les GROUPES ayant un point principal (bands.geom), pas les
// localisations. La carte, elle, clusterise band_locations : les deux chiffres
// n'ont pas la même unité et ne doivent pas être comparés naïvement.
it("compte les groupes via bands.geom, pas les localisations", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/stats" });
const sql = pool.find("FROM bands").sql;
expect(sql).toContain("geom IS NOT NULL");
expect(sql).not.toContain("band_locations");
});
});
describe("/api/facets", () => {
it("ne propose que des facettes portant sur des groupes géocodés", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/facets" });
// Sinon l'utilisateur peut cocher une facette qui ne fera apparaître
// aucun point sur la carte.
for (const call of pool.calls) {
expect(call.sql).toContain("geom IS NOT NULL");
}
});
it("borne la liste des genres", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/facets" });
expect(pool.find("genre as value").sql).toContain("LIMIT 500");
});
it("renvoie une plage d'années même quand la base est vide", async () => {
const handlers = ([{ match: /./, result: rows() }]);
const app = build(handlers);
const body = (await app.inject({ method: "GET", url: "/api/facets" })).json();
expect(body.year_range).toEqual({ min_year: null, max_year: null });
});
it("écarte les années aberrantes de la plage", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/facets" });
const sql = pool.find("MIN(formed_year)").sql;
expect(sql).toContain("formed_year >= 1900");
expect(sql).toContain("extract(year from now())");
});
});
describe("/api/bands — filtres", () => {
it("filtre sur la présence de coordonnées", async () => {
const mk = async (qs) => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: `/api/bands${qs}` });
return pool.find("FROM bands").sql;
};
expect(await mk("?geocoded=1")).toContain("geom IS NOT NULL");
expect(await mk("?geocoded=0")).toContain("geom IS NULL");
expect(await mk("")).not.toContain("geom IS");
});
it("only_black=1 filtre sur le genre", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/bands?only_black=1" });
expect(pool.find("FROM bands").sql).toContain("genre ILIKE '%black%'");
});
it("combine plusieurs filtres avec AND", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/bands?countries=NO&geocoded=1&status=Active" });
const sql = pool.find("FROM bands").sql;
expect(sql).toContain(" AND ");
expect(sql.match(/ AND /g).length).toBeGreaterThanOrEqual(2);
});
it("les statuts vides sont normalisés en 'Unknown'", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/bands?status=Unknown" });
expect(pool.find("FROM bands").sql).toContain("COALESCE(NULLIF(trim(status), ''), 'Unknown')");
});
it("un tri stable est imposé (pagination cohérente)", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/bands" });
// Sans ORDER BY déterministe, deux pages successives peuvent renvoyer
// deux fois le même groupe et en omettre un autre.
expect(pool.find("FROM bands").sql).toContain("ORDER BY ma_id ASC");
});
});
describe("/api/clusters — filtres", () => {
const base = "/api/clusters?bbox=-5,40,10,55&zoom=5";
it("applique le filtre de genre en paramètre lié", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: `${base}&genre=black` });
const call = pool.find("grid_cells");
expect(call.sql).toContain("b.genre ILIKE");
expect(call.values).toContain("%black%");
});
it("rejette un genre trop court ou pathologique", async () => {
const app = build(anyPool());
expect((await app.inject({ method: "GET", url: `${base}&genre=a` })).statusCode).toBe(400);
expect((await app.inject({ method: "GET", url: `${base}&genre=%25%25%25` })).statusCode).toBe(400);
});
it.each([
["year_min=1799", 400], ["year_min=2101", 400], ["year_min=abc", 400],
["year_max=1799", 400], ["year_max=abc", 400],
["year_min=1980&year_max=1990", 200],
])("%s → %i", async (qs, expected) => {
const app = build(anyPool());
expect((await app.inject({ method: "GET", url: `${base}&${qs}` })).statusCode).toBe(expected);
});
it("refuse plus de 100 pays et plus de 50 statuts", async () => {
const app = build(anyPool());
const c = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
const s = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
expect((await app.inject({ method: "GET", url: `${base}&countries=${c}` })).statusCode).toBe(400);
expect((await app.inject({ method: "GET", url: `${base}&status=${s}` })).statusCode).toBe(400);
});
it("la taille de cellule décroît quand le zoom augmente", async () => {
const cell = async (zoom) => {
const app = build(anyPool());
const body = (await app.inject({ method: "GET", url: `/api/clusters?bbox=-5,40,10,55&zoom=${zoom}` })).json();
return body.cell_size;
};
expect(await cell(3)).toBeGreaterThan(await cell(8));
});
it("la taille de cellule a un plancher (pas de division infinie)", async () => {
const app = build(anyPool());
const body = (await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" })).json();
expect(body.cell_size).toBeGreaterThanOrEqual(0.01);
});
it("borne le nombre de résultats", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: base });
expect(pool.find("grid_cells").sql).toContain("LIMIT 1000");
});
it("borne aussi le mode points bruts", async () => {
const handlers = anyPool();
const app = build(handlers);
await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=14" });
expect(pool.find("FROM band_locations bl").sql).toContain("LIMIT 2000");
});
});
describe("/admin/enrich/next", () => {
const TOKEN = TOKEN_FOR_APP;
const withToken = { authorization: `Bearer ${TOKEN}` };
it("exige le jeton Bearer", async () => {
const handlers = ([]);
const app = buildToken(handlers);
expect((await app.inject({ method: "GET", url: "/admin/enrich/next" })).statusCode).toBe(401);
expect(tokenPool.calls).toHaveLength(0);
});
it("ne renvoie que les groupes ayant une URL", async () => {
const handlers = ([
{ match: "FROM bands", result: rows({ ma_id: 1, url: "http://x" }, { ma_id: 2, url: null }) },
]);
const app = buildToken(handlers);
const body = (await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken })).json();
expect(body.count).toBe(1);
expect(body.items).toEqual([{ ma_id: 1, url: "http://x" }]);
});
it("borne la limite entre 1 et 500", async () => {
const lim = async (q) => {
const handlers = ([{ match: "FROM bands", result: rows() }]);
const app = buildToken(handlers);
await app.inject({ method: "GET", url: `/admin/enrich/next?limit=${q}`, headers: withToken });
return tokenPool.find("FROM bands").values.at(-1);
};
expect(await lim(9999)).toBe(500);
expect(await lim(0)).toBe(1);
expect(await lim(-5)).toBe(1);
expect(await lim("abc")).toBe(50); // valeur par défaut, pas NaN
expect(await lim(25)).toBe(25);
});
it("filtre par pays, normalisé en majuscules et lié", async () => {
const handlers = ([{ match: "FROM bands", result: rows() }]);
const app = buildToken(handlers);
await app.inject({ method: "GET", url: "/admin/enrich/next?country=no", headers: withToken });
const call = tokenPool.find("FROM bands");
expect(call.sql).toContain("country = $1");
expect(call.values[0]).toBe("NO");
});
it("ne sélectionne que les groupes jamais enrichis", async () => {
const handlers = ([{ match: "FROM bands", result: rows() }]);
const app = buildToken(handlers);
await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken });
expect(tokenPool.find("FROM bands").sql).toContain("(data->'band_page') IS NULL");
});
});
describe("/admin/auth/me et logout", () => {
it("me répond 401 sans cookie", async () => {
const app = build(anyPool());
expect((await app.inject({ method: "GET", url: "/admin/auth/me" })).statusCode).toBe(401);
});
it("me renvoie le username de la session", async () => {
const app = build(anyPool());
const res = await app.inject({
method: "GET", url: "/admin/auth/me",
headers: { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` },
});
expect(res.json()).toEqual({ ok: true, username: "nico" });
});
it("logout efface le cookie", async () => {
const app = build(anyPool());
const res = await app.inject({ method: "POST", url: "/admin/auth/logout" });
expect(res.statusCode).toBe(200);
const cookie = String(res.headers["set-cookie"]);
expect(cookie).toContain(ADMIN_COOKIE_NAME);
expect(cookie).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
it("logout ne nécessite pas de session valide (idempotent)", async () => {
const app = build(anyPool());
expect((await app.inject({ method: "POST", url: "/admin/auth/logout" })).statusCode).toBe(200);
});
});
describe("/admin/import — upsert", () => {
const TOKEN = TOKEN_FOR_APP;
const withToken = { authorization: `Bearer ${TOKEN}` };
const importPool = () => ([{ match: "INSERT INTO bands", result: rows() }]);
it("préserve les valeurs existantes quand le champ est absent (COALESCE)", async () => {
const handlers = importPool();
const app = buildToken(handlers);
await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
});
const sql = tokenPool.find("INSERT INTO bands").sql;
expect(sql).toContain("ON CONFLICT (ma_id) DO UPDATE");
expect(sql).toContain("COALESCE(EXCLUDED.name, bands.name)");
});
it("accepte `location` comme alias de `location_text`", async () => {
const handlers = importPool();
const app = buildToken(handlers);
await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1, location: "Oslo" }] },
});
expect(tokenPool.find("INSERT INTO bands").values[5]).toBe("Oslo");
});
it("marque enriched quand un objet data est fourni", async () => {
const handlers = importPool();
const app = buildToken(handlers);
await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1, data: { band_page: "..." } }] },
});
expect(tokenPool.find("INSERT INTO bands").values[7]).toBe(true);
});
it("un data non-objet n'active pas enriched", async () => {
const handlers = importPool();
const app = buildToken(handlers);
await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1, data: "pas-un-objet" }] },
});
const vals = tokenPool.find("INSERT INTO bands").values;
expect(vals[6]).toBeNull();
expect(vals[7]).toBeNull();
});
it("tous les champs passent en paramètres liés", async () => {
const handlers = importPool();
const app = buildToken(handlers);
await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1, name: "'; DROP TABLE bands--" }] },
});
const call = tokenPool.find("INSERT INTO bands");
expect(call.sql).not.toContain("DROP TABLE");
expect(call.values).toContain("'; DROP TABLE bands--");
});
it("compte exactement les lignes traitées", async () => {
const handlers = importPool();
const app = buildToken(handlers);
const res = await app.inject({
method: "POST", url: "/admin/import", headers: withToken,
payload: { bands: [{ ma_id: 1 }, { ma_id: 2 }, { ma_id: 3 }] },
});
expect(res.json().upserted).toBe(3);
expect(tokenPool.findAll("INSERT INTO bands")).toHaveLength(3);
});
it("accepte un lot de exactement 1000 groupes", async () => {
const app = buildToken(importPool());
const bands = Array.from({ length: 1000 }, (_, i) => ({ ma_id: i + 1 }));
const res = await app.inject({
method: "POST", url: "/admin/import", headers: withToken, payload: { bands },
});
expect(res.statusCode).toBe(200);
});
});