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

220 lines
7.3 KiB
JavaScript

import { describe, it, expect } from "vitest";
import {
ValidationError,
sanitizeSearchString,
parseLimitOffset,
pagination,
parseBbox,
parseZoom,
cellSizeForZoom,
parseCsvList,
parseYear,
parseLat,
parseLon,
parseMaId,
} from "../src/validate.js";
describe("sanitizeSearchString", () => {
it("trim et accepte une recherche normale", () => {
expect(sanitizeSearchString(" mayhem ")).toBe("mayhem");
});
it.each([
["trop courte", "a"],
["vide", " "],
["trop longue", "x".repeat(101)],
])("rejette une chaîne %s", (_label, input) => {
expect(() => sanitizeSearchString(input)).toThrow(ValidationError);
});
it("accepte pile 2 et 100 caractères (bornes incluses)", () => {
expect(sanitizeSearchString("ab")).toBe("ab");
expect(sanitizeSearchString("y".repeat(100))).toHaveLength(100);
});
it("rejette les caractères de contrôle", () => {
expect(() => sanitizeSearchString("may\x00hem")).toThrow(/caractères invalides/);
expect(() => sanitizeSearchString("may\x7Fhem")).toThrow(/caractères invalides/);
});
it("laisse passer tab et newline (hors plage de contrôle bloquée)", () => {
expect(sanitizeSearchString("a\tb")).toBe("a\tb");
});
it("rejette les patterns ILIKE pathologiques", () => {
expect(() => sanitizeSearchString("%%%")).toThrow(/patterns invalides/);
expect(() => sanitizeSearchString("_".repeat(10))).toThrow(/patterns invalides/);
expect(() => sanitizeSearchString("%".repeat(10))).toThrow(/patterns invalides/);
});
it("autorise un % isolé (recherche partielle légitime)", () => {
expect(sanitizeSearchString("100%")).toBe("100%");
});
it("utilise le nom de champ dans le message", () => {
expect(() => sanitizeSearchString("a", "Genre")).toThrow(/^Genre /);
});
});
describe("parseLimitOffset", () => {
it("applique les valeurs par défaut", () => {
expect(parseLimitOffset(undefined, undefined)).toEqual({ limit: 1000, offset: 0 });
expect(parseLimitOffset("", "")).toEqual({ limit: 1000, offset: 0 });
});
it("borne le limit au maximum", () => {
expect(parseLimitOffset("999999", "0", { maxLimit: 150000 }).limit).toBe(150000);
});
it("tronque les valeurs fractionnaires", () => {
expect(parseLimitOffset("10.9", "5.9")).toEqual({ limit: 10, offset: 5 });
});
// Régression : `Number("abc")` donnait NaN, propagé jusqu'au LIMIT $n
// → erreur pg → 500 au lieu d'un 400 explicite.
it("rejette un limit non numérique au lieu de propager NaN", () => {
expect(() => parseLimitOffset("abc", "0")).toThrow(ValidationError);
expect(() => parseLimitOffset("0", "0")).toThrow(/limit invalide/);
expect(() => parseLimitOffset("-5", "0")).toThrow(/limit invalide/);
});
it("rejette un offset non numérique ou négatif", () => {
expect(() => parseLimitOffset("10", "abc")).toThrow(/offset invalide/);
expect(() => parseLimitOffset("10", "-1")).toThrow(/offset invalide/);
});
it("rejette un offset au-delà du million", () => {
expect(() => parseLimitOffset("10", "1000001")).toThrow(/Offset trop grand/);
expect(parseLimitOffset("10", "1000000").offset).toBe(1000000);
});
});
describe("pagination", () => {
it("valeurs par défaut", () => {
expect(pagination({})).toEqual({ page: 1, pageSize: 50, offset: 0 });
});
it("calcule l'offset", () => {
expect(pagination({ page: "3", pageSize: "20" })).toEqual({ page: 3, pageSize: 20, offset: 40 });
});
it("borne pageSize à 200 et page à 1 minimum", () => {
expect(pagination({ pageSize: "10000" }).pageSize).toBe(200);
expect(pagination({ page: "-4" }).page).toBe(1);
expect(pagination({ page: "abc" }).page).toBe(1);
});
});
describe("parseBbox", () => {
it("parse une bbox valide", () => {
expect(parseBbox("-5,40,10,55")).toEqual({ minLon: -5, minLat: 40, maxLon: 10, maxLat: 55 });
});
it.each([
["absente", ""],
["3 valeurs", "1,2,3"],
["5 valeurs", "1,2,3,4,5"],
["non numérique", "a,b,c,d"],
["hors bornes lat", "-5,40,10,95"],
["hors bornes lon", "-181,40,10,55"],
["minLon >= maxLon", "10,40,10,55"],
["minLat >= maxLat", "-5,55,10,40"],
])("rejette une bbox %s", (_label, input) => {
expect(() => parseBbox(input)).toThrow(ValidationError);
});
});
describe("parseZoom / cellSizeForZoom", () => {
it("accepte les bornes 0 et 22", () => {
expect(parseZoom("0")).toBe(0);
expect(parseZoom("22")).toBe(22);
});
it("rejette hors bornes et non numérique", () => {
expect(() => parseZoom("-1")).toThrow(/between 0 and 22/);
expect(() => parseZoom("23")).toThrow(/between 0 and 22/);
expect(() => parseZoom("abc")).toThrow(ValidationError);
});
it("la taille de cellule décroît avec le zoom et a un plancher", () => {
expect(cellSizeForZoom(0)).toBe(180);
expect(cellSizeForZoom(4)).toBeCloseTo(11.25);
expect(cellSizeForZoom(22)).toBe(0.01);
expect(cellSizeForZoom(11)).toBeGreaterThan(cellSizeForZoom(12));
});
});
describe("parseCsvList", () => {
it("découpe, trim et filtre les vides", () => {
expect(parseCsvList("fr, de ,, be")).toEqual(["fr", "de", "be"]);
});
it("applique la transformation", () => {
expect(parseCsvList("fr,de", { transform: (s) => s.toUpperCase() })).toEqual(["FR", "DE"]);
});
it("rejette au-delà du maximum", () => {
const many = Array.from({ length: 101 }, (_, i) => `c${i}`).join(",");
expect(() => parseCsvList(many, { max: 100, label: "pays" })).toThrow(/Max 100 pays/);
expect(parseCsvList(many, { max: 101, label: "pays" })).toHaveLength(101);
});
});
describe("parseYear", () => {
it("renvoie null pour une entrée vide", () => {
expect(parseYear("")).toBeNull();
expect(parseYear(null)).toBeNull();
expect(parseYear(undefined)).toBeNull();
});
it("accepte les bornes 1800 et 2100", () => {
expect(parseYear("1800")).toBe(1800);
expect(parseYear("2100")).toBe(2100);
});
it("rejette hors bornes et non numérique", () => {
expect(() => parseYear("1799")).toThrow(ValidationError);
expect(() => parseYear("2101")).toThrow(ValidationError);
expect(() => parseYear("abc", "year_min")).toThrow(/^year_min invalide/);
});
});
describe("parseLat / parseLon", () => {
it("accepte les bornes", () => {
expect(parseLat(-90)).toBe(-90);
expect(parseLat(90)).toBe(90);
expect(parseLon(-180)).toBe(-180);
expect(parseLon(180)).toBe(180);
});
it("null efface la valeur", () => {
expect(parseLat(null)).toBeNull();
expect(parseLon("")).toBeNull();
});
it("rejette hors bornes", () => {
expect(() => parseLat(90.1)).toThrow(/lat invalide/);
expect(() => parseLon(-180.1)).toThrow(/lon invalide/);
expect(() => parseLat("nord")).toThrow(/lat invalide/);
});
// 0 est une coordonnée valide (golfe de Guinée) : ne doit pas être traité
// comme une valeur vide par un test de falsiness.
it("accepte 0", () => {
expect(parseLat(0)).toBe(0);
expect(parseLon(0)).toBe(0);
});
});
describe("parseMaId", () => {
it("accepte un entier positif, y compris au-delà de 2^31", () => {
expect(parseMaId("3500000000")).toBe(3500000000);
expect(parseMaId(0)).toBe(0);
});
it("rejette négatif, décimal et non numérique", () => {
expect(() => parseMaId("-1")).toThrow(/bad ma_id/);
expect(() => parseMaId("1.5")).toThrow(/bad ma_id/);
expect(() => parseMaId("abc")).toThrow(/bad ma_id/);
});
});