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