Sécurisation de l'API et refonte des tests backend

- 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 <noreply@anthropic.com>
This commit is contained in:
Emma781227
2026-08-17 00:22:54 +02:00
parent a2543cf4ce
commit 20c865a071
10 changed files with 219 additions and 132 deletions

View File

@@ -5,6 +5,7 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="your_secret_key" JWT_SECRET="your_secret_key"
APP_URL="http://localhost:5000" APP_URL="http://localhost:5000"
FRONTEND_URL="http://localhost:5173"
MAIL_HOST="smtp.gmail.com" MAIL_HOST="smtp.gmail.com"
MAIL_PORT="587" MAIL_PORT="587"
MAIL_USER="your_email@gmail.com" MAIL_USER="your_email@gmail.com"

View File

@@ -13,6 +13,8 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.6.1",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"prisma": "^6.19.3", "prisma": "^6.19.3",
@@ -605,9 +607,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3187,6 +3189,25 @@
"url": "https://opencollective.com/express" "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": { "node_modules/exsolve": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -3635,6 +3656,18 @@
"node": ">= 0.4" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -3743,6 +3776,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -19,6 +19,8 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.6.1",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"prisma": "^6.19.3", "prisma": "^6.19.3",

View File

@@ -1,5 +1,7 @@
const express = require("express"); const express = require("express");
const cors = require("cors"); const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
require("dotenv").config(); require("dotenv").config();
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
@@ -12,9 +14,32 @@ const swaggerSpec = require("./docs/swagger");
const app = express(); 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()); 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 // routes
app.use("/api/auth", authRoutes); app.use("/api/auth", authRoutes);
app.use("/api/game", gameRoutes); app.use("/api/game", gameRoutes);
@@ -22,7 +47,7 @@ app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes); app.use("/api/admin", adminRoutes);
// swagger // swagger
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// test route // test route
app.get("/", (req, res) => { app.get("/", (req, res) => {

View File

@@ -1,4 +1,5 @@
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const crypto = require("crypto");
const prisma = require("../config/prisma"); const prisma = require("../config/prisma");
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); 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({ const participants = await prisma.user.findMany({
where: { where: {
role: "CLIENT", 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 winner = participants[randomIndex];
const savedWinner = await prisma.grandPrizeWinner.create({ 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({ return res.json({
winner, winner: winner ?? null,
}); });
} catch (error) { } catch (error) {
console.error("Erreur récupération gagnant:", error); console.error("Erreur récupération gagnant:", error);

View File

@@ -105,9 +105,7 @@ router.post("/draw", drawWinner);
* - bearerAuth: [] * - bearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Gagnant du gros lot * description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
* 404:
* description: Aucun gagnant encore tiré
*/ */
router.get("/draw", getGrandPrizeWinner); router.get("/draw", getGrandPrizeWinner);

View File

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

View File

@@ -10,53 +10,58 @@ 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 () => { beforeAll(async () => {
await prisma.claim.deleteMany(); await prisma.user.deleteMany({ where: { email: testEmail } });
await prisma.participation.deleteMany(); await prisma.ticket.deleteMany({ where: { code: ticketCode } });
await prisma.ticket.deleteMany();
await prisma.user.deleteMany();
}); });
afterAll(async () => { 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(); await prisma.$disconnect();
}); });
describe("THE TIP TOP - FULL E2E FLOW", () => { describe("THE TIP TOP - FULL E2E FLOW", () => {
let token;
let ticketCode = "ABC123";
// ------------------------
// 1. REGISTER
// ------------------------
test("Register user", async () => { test("Register user", async () => {
const res = await request(app) const res = await request(app).post("/api/auth/register").send({
.post("/api/auth/register") firstname: "E2E",
.send({ lastname: "Test",
firstname: "Test", email: testEmail,
lastname: "User",
email: "test@test.com",
password: "Password123", password: "Password123",
}); });
expect([201, 409]).toContain(res.statusCode); expect(res.statusCode).toBe(201);
}); });
// ------------------------
// 2. LOGIN
// ------------------------
test("Login user", async () => { test("Login user", async () => {
// Validation du user pour bypasser vérification email
await prisma.user.update({ await prisma.user.update({
where: { email: "test@test.com" }, where: { email: testEmail },
data: { isEmailVerified: true }, data: { isEmailVerified: true },
}); });
const res = await request(app) const res = await request(app).post("/api/auth/login").send({
.post("/api/auth/login") email: testEmail,
.send({
email: "test@test.com",
password: "Password123", password: "Password123",
}); });
@@ -65,70 +70,28 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
token = res.body.token; token = res.body.token;
}); });
// ------------------------ test("Create a dedicated ticket for this test", async () => {
// 3. PARTICIPATE GAME const lot = await prisma.lot.findFirst();
// ------------------------ expect(lot).not.toBeNull();
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
},
});
const ticket = await prisma.ticket.create({ const ticket = await prisma.ticket.create({
data: { data: { code: ticketCode, lotId: lot.id },
code: ticketCode,
lotId: lot.id
}
}); });
createdTicketId = ticket.id;
expect(ticket).toBeDefined();
expect(ticket.code).toBe(ticketCode); expect(ticket.code).toBe(ticketCode);
}); });
// ------------------------
// 4. PARTICIPATE GAME
// ------------------------
test("Participate to game", async () => { test("Participate to game", async () => {
const res = await request(app) const res = await request(app)
.post("/api/game/participate") .post("/api/game/participate")
.set("Authorization", `Bearer ${token}`) .set("Authorization", `Bearer ${token}`)
.send({ code: ticketCode }); .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 () => { test("Get my gains", async () => {
const res = await request(app) const res = await request(app)
.get("/api/game/my-gains") .get("/api/game/my-gains")
@@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body.gains).toBeDefined(); 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 () => { test("Admin stats - CLIENT access denied", async () => {
const res = await request(app) const res = await request(app)
.get("/api/admin/stats") .get("/api/admin/stats")

View File

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

View File

@@ -41,7 +41,6 @@ export async function getEmailingContactsApi() {
export async function getWinnerApi() { export async function getWinnerApi() {
const res = await fetch('/api/admin/draw', { headers: authHeaders() }) const res = await fetch('/api/admin/draw', { headers: authHeaders() })
if (res.status === 404) return null
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
return data return data