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 IP, y compris pour un appelant authentifié", async () => { // Ce test verrouillait auparavant l'inverse — un compteur par jeton, pour // qu'un client bruyant n'affame pas les autres. L'intention se défend, mais // le limiteur s'exécute AVANT l'authentification : la cle etait donc une // valeur non verifiee, fournie par l'appelant. Isoler par identite reelle // est impossible a cette couche ; on retombe sur l'IP, seule cle que // l'appelant ne choisit pas. const app = await appWithRealLimits({ adminRateLimitMax: 2 }); const call = (token) => app.inject({ method: "POST", url: "/admin/import", headers: { "x-forwarded-for": "4.4.4.4", authorization: `Bearer ${token}` }, payload: { bands: [] }, }); await call(TOKEN); await call(TOKEN); expect((await call(TOKEN)).statusCode).toBe(429); 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("le plafond d'import ne se contourne pas en changeant de jeton", async () => { // Le limiteur etait keye sur le Bearer fourni, c'est-a-dire sur une valeur // entierement controlee par l'appelant : envoyer un jeton different a // chaque requete donnait un seau neuf a chaque fois, et le plafond ne // freinait plus rien -- alors qu'il est justement la pour ralentir la // recherche du jeton par force brute. const app = await appWithRealLimits({ adminRateLimitMax: 2 }); const call = (token) => app.inject({ method: "POST", url: "/admin/import", headers: { "x-forwarded-for": "9.9.9.9", authorization: `Bearer ${token}` }, payload: { bands: [] }, }); expect((await call("faux-a")).statusCode).toBe(401); expect((await call("faux-b")).statusCode).toBe(401); // 3e requete depuis la meme IP : bloquee, quel que soit le jeton presente. expect((await call("faux-c")).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(); }); });