From 20c865a071fd4dda0a25b9798b719c655dbf017e Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Mon, 17 Aug 2026 00:22:54 +0200 Subject: [PATCH] =?UTF-8?q?S=C3=A9curisation=20de=20l'API=20et=20refonte?= =?UTF-8?q?=20des=20tests=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - helmet et express-rate-limit sur les routes d'authentification - CORS restreint aux origines de FRONTEND_URL - CSP désactivée uniquement sur /api-docs pour Swagger UI - ajout du test de tirage et simplification du test e2e - suppression de tests/setup.js devenu inutile Co-Authored-By: Claude Opus 5 --- backend/.env.example | 1 + backend/package-lock.json | 48 +++++++- backend/package.json | 2 + backend/src/app.js | 29 ++++- backend/src/controllers/admin.controller.js | 19 +-- backend/src/routes/admin.routes.js | 4 +- backend/tests/draw.test.js | 87 +++++++++++++ backend/tests/e2e.flow.test.js | 129 +++++++------------- backend/tests/setup.js | 31 ----- frontend/src/services/admin.js | 1 - 10 files changed, 219 insertions(+), 132 deletions(-) create mode 100644 backend/tests/draw.test.js delete mode 100644 backend/tests/setup.js diff --git a/backend/.env.example b/backend/.env.example index 08da568..3134cbe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,7 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db" JWT_SECRET="your_secret_key" APP_URL="http://localhost:5000" +FRONTEND_URL="http://localhost:5173" MAIL_HOST="smtp.gmail.com" MAIL_PORT="587" MAIL_USER="your_email@gmail.com" diff --git a/backend/package-lock.json b/backend/package-lock.json index b957f37..d1c0f8d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,8 @@ "bcrypt": "^6.0.0", "cors": "^2.8.6", "express": "^5.2.1", + "express-rate-limit": "^8.6.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "prisma": "^6.19.3", @@ -605,9 +607,9 @@ "license": "MIT" }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -3187,6 +3189,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -3635,6 +3656,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3743,6 +3776,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 30506c4..4e7c48b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,8 @@ "bcrypt": "^6.0.0", "cors": "^2.8.6", "express": "^5.2.1", + "express-rate-limit": "^8.6.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "prisma": "^6.19.3", diff --git a/backend/src/app.js b/backend/src/app.js index 0687edd..856e21e 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,5 +1,7 @@ const express = require("express"); const cors = require("cors"); +const helmet = require("helmet"); +const rateLimit = require("express-rate-limit"); require("dotenv").config(); const authRoutes = require("./routes/auth.routes"); @@ -12,9 +14,32 @@ const swaggerSpec = require("./docs/swagger"); const app = express(); -app.use(cors()); +const allowedOrigins = (process.env.FRONTEND_URL || "http://localhost:5173").split(","); + +// La CSP par défaut de helmet bloque les scripts inline de Swagger UI : +// on l'applique partout sauf sur /api-docs, où une CSP dédiée (désactivée) +// est posée juste avant de monter Swagger, plus bas. +app.use((req, res, next) => { + if (req.path.startsWith("/api-docs")) return next(); + return helmet()(req, res, next); +}); +app.use(cors({ origin: allowedOrigins })); app.use(express.json()); +// Anti-bruteforce sur les routes d'authentification uniquement : 20 +// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API +// (participation au jeu, consultation des gains, etc.). +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + message: { message: "Trop de tentatives, réessayez dans quelques minutes." }, +}); +app.use("/api/auth/login", authLimiter); +app.use("/api/auth/register", authLimiter); +app.use("/api/auth/forgot-password", authLimiter); + // routes app.use("/api/auth", authRoutes); app.use("/api/game", gameRoutes); @@ -22,7 +47,7 @@ app.use("/api/employee", employeeRoutes); app.use("/api/admin", adminRoutes); // swagger -app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec)); // test route app.get("/", (req, res) => { diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 8651bf0..5be295f 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,4 +1,5 @@ const bcrypt = require("bcrypt"); +const crypto = require("crypto"); const prisma = require("../config/prisma"); const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); @@ -234,6 +235,10 @@ const drawWinner = async (req, res) => { }); } + // Un utilisateur = une entrée dans le pool, quel que soit son nombre de + // participations. On sélectionne des CLIENT distincts (pas des + // participations), donc avoir utilisé 10 tickets ou 1 seul ne change + // strictement rien aux chances de gagner le gros lot. const participants = await prisma.user.findMany({ where: { role: "CLIENT", @@ -255,7 +260,11 @@ const drawWinner = async (req, res) => { }); } - const randomIndex = Math.floor(Math.random() * participants.length); + // Tirage purement aléatoire et équiprobable parmi les participants + // distincts. crypto.randomInt (générateur cryptographiquement sûr, + // sans biais) plutôt que Math.random(), pour un tirage défendable + // devant l'huissier qui le supervise. + const randomIndex = crypto.randomInt(participants.length); const winner = participants[randomIndex]; const savedWinner = await prisma.grandPrizeWinner.create({ @@ -302,14 +311,8 @@ const getGrandPrizeWinner = async (req, res) => { }, }); - if (!winner) { - return res.status(404).json({ - message: "Aucun gagnant du gros lot n'a encore été tiré.", - }); - } - return res.json({ - winner, + winner: winner ?? null, }); } catch (error) { console.error("Erreur récupération gagnant:", error); diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index 002d31d..e5ee7cf 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -105,9 +105,7 @@ router.post("/draw", drawWinner); * - bearerAuth: [] * responses: * 200: - * description: Gagnant du gros lot - * 404: - * description: Aucun gagnant encore tiré + * description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu */ router.get("/draw", getGrandPrizeWinner); diff --git a/backend/tests/draw.test.js b/backend/tests/draw.test.js new file mode 100644 index 0000000..763e776 --- /dev/null +++ b/backend/tests/draw.test.js @@ -0,0 +1,87 @@ +const bcrypt = require("bcrypt"); +const prisma = require("../src/config/prisma"); + +// ------------------------ +// Vérifie que le pool de participants au tirage du gros lot ne compte +// chaque client qu'une seule fois, quel que soit son nombre de +// participations. On rejoue ici exactement la requête utilisée par +// admin.controller.js::drawWinner, restreinte à des utilisateurs dédiés +// pour ne pas dépendre des autres données déjà présentes en base +// (et surtout ne jamais toucher aux vrais tickets du jeu-concours). +// ------------------------ + +let userManyParticipations; +let userOneParticipation; +let ticketIds = []; + +beforeAll(async () => { + const lot = await prisma.lot.findFirst(); + if (!lot) throw new Error("Aucun lot en base : lancez `npx prisma db seed` avant ce test."); + + const hashed = await bcrypt.hash("Password123", 10); + + userManyParticipations = await prisma.user.create({ + data: { + firstname: "Draw", + lastname: "MultiParticip", + email: "draw-test-multi@test.com", + password: hashed, + role: "CLIENT", + isEmailVerified: true, + }, + }); + + userOneParticipation = await prisma.user.create({ + data: { + firstname: "Draw", + lastname: "SingleParticip", + email: "draw-test-single@test.com", + password: hashed, + role: "CLIENT", + isEmailVerified: true, + }, + }); + + const codes = ["ZZDRAWTEST1", "ZZDRAWTEST2", "ZZDRAWTEST3", "ZZDRAWTEST4"]; + const owners = [ + userManyParticipations, + userManyParticipations, + userManyParticipations, + userOneParticipation, + ]; + + for (let i = 0; i < codes.length; i++) { + const ticket = await prisma.ticket.create({ + data: { code: codes[i], lotId: lot.id, isUsed: true, usedAt: new Date(), userId: owners[i].id }, + }); + ticketIds.push(ticket.id); + await prisma.participation.create({ + data: { userId: owners[i].id, ticketId: ticket.id, lotId: lot.id }, + }); + } +}); + +afterAll(async () => { + await prisma.participation.deleteMany({ where: { ticketId: { in: ticketIds } } }); + await prisma.ticket.deleteMany({ where: { id: { in: ticketIds } } }); + await prisma.user.deleteMany({ + where: { id: { in: [userManyParticipations.id, userOneParticipation.id] } }, + }); + await prisma.$disconnect(); +}); + +test("un client avec plusieurs participations ne compte qu'une fois dans le pool du tirage", async () => { + const eligible = await prisma.user.findMany({ + where: { + role: "CLIENT", + id: { in: [userManyParticipations.id, userOneParticipation.id] }, + participations: { some: {} }, + }, + select: { id: true }, + }); + + expect(eligible).toHaveLength(2); + expect(eligible.map((u) => u.id).sort()).toEqual( + [userManyParticipations.id, userOneParticipation.id].sort() + ); +}); diff --git a/backend/tests/e2e.flow.test.js b/backend/tests/e2e.flow.test.js index fd0b741..ddb2ae2 100644 --- a/backend/tests/e2e.flow.test.js +++ b/backend/tests/e2e.flow.test.js @@ -10,125 +10,88 @@ jest.mock("../src/config/mailer", () => ({ })); // ------------------------ -// Réinitialisation DB avant tests +// Ce test tourne sur la même base que le reste du dev (500 000 tickets +// réels du jeu-concours). On ne touche donc JAMAIS les tables globales +// (pas de deleteMany sur claim/participation/ticket/user/lot) : tout ce +// que ce fichier crée est identifiable de façon unique et nettoyé dans +// afterAll, rien d'autre. // ------------------------ + +const testEmail = "e2e-flow-test@test.com"; +const ticketCode = "ZZE2ETEST1"; +let token; +let createdTicketId; + beforeAll(async () => { - await prisma.claim.deleteMany(); - await prisma.participation.deleteMany(); - await prisma.ticket.deleteMany(); - await prisma.user.deleteMany(); + await prisma.user.deleteMany({ where: { email: testEmail } }); + await prisma.ticket.deleteMany({ where: { code: ticketCode } }); }); afterAll(async () => { + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + if (user) { + await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } }); + await prisma.participation.deleteMany({ where: { userId: user.id } }); + } + if (createdTicketId) { + await prisma.ticket.delete({ where: { id: createdTicketId } }).catch(() => {}); + } + if (user) { + await prisma.user.delete({ where: { id: user.id } }); + } await prisma.$disconnect(); }); describe("THE TIP TOP - FULL E2E FLOW", () => { - let token; - let ticketCode = "ABC123"; - - // ------------------------ - // 1. REGISTER - // ------------------------ test("Register user", async () => { - const res = await request(app) - .post("/api/auth/register") - .send({ - firstname: "Test", - lastname: "User", - email: "test@test.com", - password: "Password123", - }); + const res = await request(app).post("/api/auth/register").send({ + firstname: "E2E", + lastname: "Test", + email: testEmail, + password: "Password123", + }); - expect([201, 409]).toContain(res.statusCode); + expect(res.statusCode).toBe(201); }); - // ------------------------ - // 2. LOGIN - // ------------------------ test("Login user", async () => { - // Validation du user pour bypasser vérification email await prisma.user.update({ - where: { email: "test@test.com" }, + where: { email: testEmail }, data: { isEmailVerified: true }, }); - const res = await request(app) - .post("/api/auth/login") - .send({ - email: "test@test.com", - password: "Password123", - }); + const res = await request(app).post("/api/auth/login").send({ + email: testEmail, + password: "Password123", + }); expect(res.statusCode).toBe(200); expect(res.body.token).toBeDefined(); token = res.body.token; }); - // ------------------------ - // 3. PARTICIPATE GAME - // ------------------------ - test("Participate to game", async () => { - const res = await request(app) - .post("/api/game/participate") - .set("Authorization", `Bearer ${token}`) - .send({ code: ticketCode }); - - expect([200, 201, 404, 409, 400]).toContain(res.statusCode); - console.log("Ticket in DB before participate:", ticketCode); - }); - - // ------------------------ - // 4. MY GAINS - // ------------------------ - test("Get my gains", async () => { - const res = await request(app) - .get("/api/game/my-gains") - .set("Authorization", `Bearer ${token}`); - - expect(res.statusCode).toBe(200); - expect(res.body.gains).toBeDefined(); - }); - - // ------------------------ - // 3. GENERER UN TICKET - // ------------------------ - test("Create a ticket for testing", async () => { - const lot = await prisma.lot.create({ - data: { - name: "Lot Test", - description: "Lot pour test E2E", - value: 10, - percentage: 100 - }, - }); + test("Create a dedicated ticket for this test", async () => { + const lot = await prisma.lot.findFirst(); + expect(lot).not.toBeNull(); const ticket = await prisma.ticket.create({ - data: { - code: ticketCode, - lotId: lot.id - } + data: { code: ticketCode, lotId: lot.id }, }); + createdTicketId = ticket.id; - expect(ticket).toBeDefined(); expect(ticket.code).toBe(ticketCode); }); - // ------------------------ - // 4. PARTICIPATE GAME - // ------------------------ test("Participate to game", async () => { const res = await request(app) .post("/api/game/participate") .set("Authorization", `Bearer ${token}`) .send({ code: ticketCode }); - expect([200, 201, 404, 409, 400]).toContain(res.statusCode); + expect(res.statusCode).toBe(201); + expect(res.body.gain).toBeDefined(); }); - // ------------------------ - // 5. MY GAINS - // ------------------------ test("Get my gains", async () => { const res = await request(app) .get("/api/game/my-gains") @@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { expect(res.statusCode).toBe(200); expect(res.body.gains).toBeDefined(); + expect(res.body.gains.some((g) => g.ticketCode === ticketCode)).toBe(true); }); - // ------------------------ - // 6. ADMIN STATS (CLIENT access denied) - // ------------------------ test("Admin stats - CLIENT access denied", async () => { const res = await request(app) .get("/api/admin/stats") @@ -149,4 +110,4 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { expect(res.statusCode).toBe(403); expect(res.body.message || res.body).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js deleted file mode 100644 index 0f5b280..0000000 --- a/backend/tests/setup.js +++ /dev/null @@ -1,31 +0,0 @@ -const prisma = require("../src/config/prisma"); - -beforeAll(async () => { - await prisma.claim.deleteMany(); - await prisma.participation.deleteMany(); - await prisma.ticket.deleteMany(); - await prisma.user.deleteMany(); - - // Création de lots - await prisma.lot.createMany({ - data: [ - { name: "Infuseur", value: 10, percentage: 60, description: "Infuseur à thé" }, - { name: "Thé Détox", value: 15, percentage: 20, description: "Thé détox bio" }, - { name: "Thé Signature", value: 20, percentage: 10, description: "Thé signature" }, - { name: "Coffret 39€", value: 39, percentage: 6, description: "Coffret découverte" }, - { name: "Coffret 69€", value: 69, percentage: 4, description: "Coffret premium" }, - ] - }); - - // Création d'un ticket - await prisma.ticket.create({ - data: { - code: "ABC123", - lotId: 1 - } - }); -}); - -afterAll(async () => { - await prisma.$disconnect(); -}); diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index bf5bf4f..152d288 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -41,7 +41,6 @@ export async function getEmailingContactsApi() { export async function getWinnerApi() { const res = await fetch('/api/admin/draw', { headers: authHeaders() }) - if (res.status === 404) return null const data = await res.json() if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) return data