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>
220 lines
7.3 KiB
JavaScript
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/);
|
|
});
|
|
});
|