diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js index ecd1c07..82ff4c2 100644 --- a/apps/api/src/adminRoutes.js +++ b/apps/api/src/adminRoutes.js @@ -1,5 +1,5 @@ import { requireAdminSession, writeAuditLog } from "./adminAuth.js"; -import { pagination } from "./validate.js"; +import { ValidationError, pagination, parseLat, parseLon, parseYear } from "./validate.js"; const BAND_SORT_COLUMNS = new Set([ "ma_id", "name", "country", "status", "genre", @@ -248,26 +248,18 @@ export default async function adminRoutes(fastify, opts) { if (Object.keys(updates).length === 0) { return reply.code(400).send({ ok: false, error: "Aucun champ à modifier" }); } - if ("formed_year" in updates) { - const y = updates.formed_year === null ? null : Number(updates.formed_year); - if (y !== null && (!Number.isFinite(y) || y < 1800 || y > 2100)) { - return reply.code(400).send({ ok: false, error: "formed_year invalide" }); + // Mêmes règles que les routes publiques, et une seule implémentation : + // elles étaient recopiées à la main ici alors que validate.js les portait + // déjà, testées par 64 cas qui ne couvraient donc pas ce qui tournait. + try { + if ("formed_year" in updates) updates.formed_year = parseYear(updates.formed_year, "formed_year"); + if ("lat" in updates) updates.lat = parseLat(updates.lat); + if ("lon" in updates) updates.lon = parseLon(updates.lon); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); } - updates.formed_year = y; - } - if ("lat" in updates) { - const v = updates.lat === null ? null : Number(updates.lat); - if (v !== null && (!Number.isFinite(v) || v < -90 || v > 90)) { - return reply.code(400).send({ ok: false, error: "lat invalide" }); - } - updates.lat = v; - } - if ("lon" in updates) { - const v = updates.lon === null ? null : Number(updates.lon); - if (v !== null && (!Number.isFinite(v) || v < -180 || v > 180)) { - return reply.code(400).send({ ok: false, error: "lon invalide" }); - } - updates.lon = v; + throw err; } // Transaction : l'edition, la pose de l'override de localisation et @@ -766,17 +758,20 @@ export default async function adminRoutes(fastify, opts) { return reply.code(400).send({ ok: false, error: "bad id" }); } const { lat, lon } = req.body || {}; - const latN = lat === null || lat === undefined || lat === "" ? null : Number(lat); - const lonN = lon === null || lon === undefined || lon === "" ? null : Number(lon); + let latN, lonN; + try { + latN = parseLat(lat); + lonN = parseLon(lon); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); + } + throw err; + } + // parseLat/parseLon acceptent null (effacement) ; ici les deux sont requis. if (latN === null || lonN === null) { return reply.code(400).send({ ok: false, error: "lat et lon requis" }); } - if (!Number.isFinite(latN) || latN < -90 || latN > 90) { - return reply.code(400).send({ ok: false, error: "lat invalide" }); - } - if (!Number.isFinite(lonN) || lonN < -180 || lonN > 180) { - return reply.code(400).send({ ok: false, error: "lon invalide" }); - } const before = await pool.query(`SELECT * FROM band_locations WHERE id = $1`, [id]); if (!before.rows.length) return reply.code(404).send({ ok: false, error: "not found" }); diff --git a/apps/api/src/app.js b/apps/api/src/app.js index 7009f7a..c468ca6 100644 --- a/apps/api/src/app.js +++ b/apps/api/src/app.js @@ -22,7 +22,17 @@ import { requireAdminSession, } from "./adminAuth.js"; import adminApiRoutes from "./adminRoutes.js"; -import { ValidationError, sanitizeSearchString, parseLimitOffset } from "./validate.js"; +import { + ValidationError, + sanitizeSearchString, + parseLimitOffset, + parseBbox, + parseZoom, + cellSizeForZoom, + parseCsvList, + parseYear, + parseMaId, +} from "./validate.js"; /** * @param {object} [opts] @@ -282,35 +292,22 @@ export async function buildServer(opts = {}) { year_max, } = /** @type {Record} */ (req.query || {}); - if (!bbox || !zoom) { - return reply.code(400).send({ ok: false, error: "bbox and zoom are required" }); + // Validation déléguée à validate.js. Ces règles y étaient déjà écrites ET + // couvertes par 64 tests, mais la production les réimplémentait à la main + // ici : les tests garantissaient donc une implémentation qui ne tournait + // nulle part. Une divergence existait déjà (parseMaId exige un entier, le + // code en ligne acceptait un flottant). + let minLon, minLat, maxLon, maxLat, zoomLevel, cellSize; + try { + ({ minLon, minLat, maxLon, maxLat } = parseBbox(bbox)); + zoomLevel = parseZoom(zoom); + cellSize = cellSizeForZoom(zoomLevel); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); + } + throw err; } - - const bboxParts = String(bbox).split(",").map(Number); - if (bboxParts.length !== 4) { - return reply.code(400).send({ ok: false, error: "bbox must have 4 values" }); - } - - const [minLon, minLat, maxLon, maxLat] = bboxParts; - const zoomLevel = Number(zoom); - - if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { - return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" }); - } - - if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) { - return reply.code(400).send({ ok: false, error: "Coordinates out of range" }); - } - - if (minLon >= maxLon || minLat >= maxLat) { - return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" }); - } - - if (zoomLevel < 0 || zoomLevel > 22) { - return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" }); - } - - const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel)); // Source: band_locations (un point par localisation géocodée) JOIN bands. const where = [ @@ -324,28 +321,29 @@ export async function buildServer(opts = {}) { vals.push(minLon, minLat, maxLon, maxLat); i += 4; - if (countries) { - const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); - if (cs.length > 100) { - return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" }); + try { + if (countries) { + const cs = parseCsvList(countries, { max: 100, label: "pays", transform: (x) => x.toUpperCase() }); + if (cs.length) { + where.push(`b.country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } } - if (cs.length) { - where.push(`b.country = ANY($${i}::text[])`); - vals.push(cs); - i++; - } - } - if (status) { - const st = String(status).split(",").map(s => s.trim()).filter(Boolean); - if (st.length > 50) { - return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" }); + if (status) { + const st = parseCsvList(status, { max: 50, label: "statuts" }); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } } - if (st.length) { - where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`); - vals.push(st); - i++; + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); } + throw err; } if (genre) { @@ -359,24 +357,24 @@ export async function buildServer(opts = {}) { } } - if (year_min) { - const yearMin = Number(year_min); - if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) { - return reply.code(400).send({ ok: false, error: 'year_min invalide' }); + try { + const yearMin = parseYear(year_min, "year_min"); + if (yearMin !== null) { + where.push(`b.formed_year >= $${i}`); + vals.push(yearMin); + i++; } - where.push(`b.formed_year >= $${i}`); - vals.push(yearMin); - i++; - } - - if (year_max) { - const yearMax = Number(year_max); - if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) { - return reply.code(400).send({ ok: false, error: 'year_max invalide' }); + const yearMax = parseYear(year_max, "year_max"); + if (yearMax !== null) { + where.push(`b.formed_year <= $${i}`); + vals.push(yearMax); + i++; } - where.push(`b.formed_year <= $${i}`); - vals.push(yearMax); - i++; + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); + } + throw err; } const whereSql = where.join(" AND "); @@ -526,31 +524,39 @@ export async function buildServer(opts = {}) { const vals = []; let i = 1; - if (countries) { - const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); - if (cs.length > 100) { - return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" }); + try { + if (countries) { + const cs = parseCsvList(countries, { max: 100, label: "pays", transform: (x) => x.toUpperCase() }); + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } } - if (cs.length) { - where.push(`country = ANY($${i}::text[])`); - vals.push(cs); - i++; + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); } + throw err; } if (geocoded === "1") where.push(`geom IS NOT NULL`); if (geocoded === "0") where.push(`geom IS NULL`); - if (status) { - const st = String(status).split(",").map(s => s.trim()).filter(Boolean); - if (st.length > 50) { - return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" }); + try { + if (status) { + const st = parseCsvList(status, { max: 50, label: "statuts" }); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } } - if (st.length) { - where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); - vals.push(st); - i++; + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); } + throw err; } if (only_black === "1") { @@ -667,10 +673,18 @@ export async function buildServer(opts = {}) { try { const p = requirePool(); const { ma_id } = /** @type {{ ma_id: string }} */ (req.params); - const id = Number(ma_id); - - if (!Number.isFinite(id) || id < 0) { - return reply.code(400).send({ ok: false, error: "bad ma_id" }); + let id; + try { + // parseMaId exige un ENTIER. Le contrôle en ligne se contentait de + // Number.isFinite : /api/band/1.5 passait la validation et partait en + // base pour ne rien trouver. C'est la divergence que la duplication + // avait laissée s'installer. + id = parseMaId(ma_id); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ ok: false, error: err.message }); + } + throw err; } const r = await p.query( diff --git a/apps/api/test/validateWiring.test.js b/apps/api/test/validateWiring.test.js new file mode 100644 index 0000000..ed5c0e6 --- /dev/null +++ b/apps/api/test/validateWiring.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * validate.js ne doit pas redevenir du code mort bien testé. + * + * Huit de ses douze exports n'étaient appelés nulle part en production : + * app.js et adminRoutes.js réimplémentaient la même validation à la main, trois + * fois. Les 64 tests de validate.test.js et validate.property.test.js + * garantissaient donc une implémentation qui ne tournait nulle part — et une + * divergence s'était déjà installée sans que rien ne rougisse (parseMaId exige + * un entier, le contrôle en ligne acceptait un flottant, si bien que + * /api/band/1.5 était accepté). + * + * Ce test échoue si un helper cesse d'être branché. Il ne dit rien de sa + * qualité — c'est le rôle des deux autres fichiers — seulement qu'il est + * réellement sur le chemin d'exécution. + */ +const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src"); +const lire = (f) => fs.readFileSync(path.join(SRC, f), "utf8"); + +const validate = lire("validate.js"); +const consommateurs = ["app.js", "adminRoutes.js"].map(lire).join("\n"); + +const exports = [...validate.matchAll(/export\s+(?:async\s+)?(?:class|function)\s+(\w+)/g)] + .map((m) => m[1]); + +describe("branchement de validate.js", () => { + it("expose au moins les douze helpers connus", () => { + expect(exports.length).toBeGreaterThanOrEqual(12); + }); + + it.each(exports)("%s est réellement utilisé en production", (nom) => { + const utilise = new RegExp(`\\b${nom}\\b`).test(consommateurs); + expect(utilise).toBe(true); + }); +});