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

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();
});
});