metalfrom.eu/apps/api/test/adminRoutes.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

388 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 ||");
expect(upd.values.at(-1)).toBe(JSON.stringify({ name: true, country: true }));
});
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]);
});
});