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>
388 lines
14 KiB
JavaScript
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]);
|
|
});
|
|
});
|