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>
126 lines
4.8 KiB
JavaScript
126 lines
4.8 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const { createFullAppWith } = await import("./helpers/testApp.js");
|
|
const { makeFakePool, rows } = await import("./helpers/fakePool.js");
|
|
|
|
/**
|
|
* Les autres fichiers de test partagent une app avec des plafonds de débit
|
|
* neutralisés (sinon l'état du limiteur fuiterait d'un cas à l'autre). C'EST
|
|
* ICI, et seulement ici, que les vraies valeurs de production sont vérifiées —
|
|
* sinon la paramétrisation introduite pour la performance créerait un angle
|
|
* mort sur une protection de sécurité.
|
|
*/
|
|
|
|
const TOKEN = "jeton-de-test";
|
|
|
|
async function appWithRealLimits(overrides = {}) {
|
|
return createFullAppWith({
|
|
pool: makeFakePool([{ match: /./, result: rows({ total: 0 }) }]),
|
|
importToken: TOKEN,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("plafond global", () => {
|
|
it("répond 429 au-delà du plafond", async () => {
|
|
const app = await appWithRealLimits({ globalRateLimitMax: 3 });
|
|
const codes = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
codes.push((await app.inject({ method: "GET", url: "/api/health" })).statusCode);
|
|
}
|
|
expect(codes).toEqual([200, 200, 200, 429, 429]);
|
|
await app.close();
|
|
});
|
|
|
|
it("le 429 traverse le gestionnaire d'erreurs sans devenir un 500", async () => {
|
|
// Régression : setErrorHandler écrasait tous les statuts en 500, ce qui
|
|
// transformait une limitation de débit en fausse panne serveur.
|
|
const app = await appWithRealLimits({ globalRateLimitMax: 1 });
|
|
await app.inject({ method: "GET", url: "/api/health" });
|
|
const res = await app.inject({ method: "GET", url: "/api/health" });
|
|
expect(res.statusCode).toBe(429);
|
|
expect(res.json().ok).toBe(false);
|
|
await app.close();
|
|
});
|
|
|
|
it("la valeur de production par défaut est 1000/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({ method: "GET", url: "/api/health" });
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("1000");
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
describe("plafond de /admin/import", () => {
|
|
it("la valeur de production par défaut est 10/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands: [] },
|
|
});
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("10");
|
|
await app.close();
|
|
});
|
|
|
|
it("limite par jeton, pas globalement", async () => {
|
|
const app = await appWithRealLimits({ adminRateLimitMax: 2 });
|
|
const call = (token) =>
|
|
app.inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${token}` }, payload: { bands: [] },
|
|
});
|
|
|
|
await call(TOKEN);
|
|
await call(TOKEN);
|
|
// Le 3e appel avec CE jeton est bloqué…
|
|
expect((await call(TOKEN)).statusCode).toBe(429);
|
|
// …mais un autre jeton dispose de son propre compteur (il sera rejeté en
|
|
// 401, pas en 429 : c'est bien l'authentification qui tranche, pas le débit).
|
|
expect((await call("un-autre-jeton-de-la-meme-taille")).statusCode).toBe(401);
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
describe("plafond de /admin/auth/login", () => {
|
|
it("la valeur de production par défaut est 10/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/auth/login", payload: { username: "", password: "" },
|
|
});
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("10");
|
|
await app.close();
|
|
});
|
|
|
|
// Le verrouillage de compte (5 échecs / 15 min, en base) et le rate-limit HTTP
|
|
// sont deux protections distinctes : celle-ci s'applique même à des requêtes
|
|
// malformées qui n'atteignent jamais la base.
|
|
it("limite le bruteforce même sur des corps invalides", async () => {
|
|
const app = await appWithRealLimits({ authRateLimitMax: 2 });
|
|
const call = () =>
|
|
app.inject({ method: "POST", url: "/admin/auth/login", payload: {} });
|
|
|
|
expect((await call()).statusCode).toBe(400);
|
|
expect((await call()).statusCode).toBe(400);
|
|
expect((await call()).statusCode).toBe(429);
|
|
await app.close();
|
|
});
|
|
|
|
it("limite par IP", async () => {
|
|
const app = await appWithRealLimits({ authRateLimitMax: 1 });
|
|
const call = (ip) =>
|
|
app.inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
headers: { "x-forwarded-for": ip },
|
|
payload: {},
|
|
});
|
|
|
|
expect((await call("1.2.3.4")).statusCode).toBe(400);
|
|
expect((await call("1.2.3.4")).statusCode).toBe(429);
|
|
// Une autre IP garde son propre quota (trustProxy est actif : l'app est
|
|
// derrière Traefik, l'IP réelle vient de X-Forwarded-For).
|
|
expect((await call("5.6.7.8")).statusCode).toBe(400);
|
|
await app.close();
|
|
});
|
|
});
|