From 20c865a071fd4dda0a25b9798b719c655dbf017e Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Mon, 17 Aug 2026 00:22:54 +0200 Subject: [PATCH 1/6] =?UTF-8?q?S=C3=A9curisation=20de=20l'API=20et=20refon?= =?UTF-8?q?te=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 From 464d666343355467ae2dacee63f04732e66746b7 Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Mon, 17 Aug 2026 00:23:18 +0200 Subject: [PATCH 2/6] Connexion via Google (OAuth 2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'authentification Google en complément de la connexion par mot de passe. Le callback OAuth émet le même JWT que la connexion classique, donc le middleware d'authentification et ProtectedRoute restent inchangés. Backend - stratégie passport-google-oauth20 dans src/config/passport.js - routes GET /api/auth/google et /google/callback, documentées dans Swagger - un compte existant avec le même email est rattaché automatiquement au compte Google, et son email passe vérifié - les routes répondent 503 si les identifiants ne sont pas configurés, plutôt que de faire échouer le démarrage du serveur - login et changePassword ne comparent plus un hash inexistant sur les comptes sans mot de passe Base de données - password devient nullable - colonnes provider, providerId et avatarUrl, index unique sur le couple provider/providerId Frontend - page /auth/callback qui récupère le token et redirige selon le rôle - boutons Google de Connexion et Inscription branchés - boutons Facebook désactivés en attendant les identifiants Meta Co-Authored-By: Claude Opus 5 --- backend/.env.example | 8 ++ backend/package-lock.json | 95 +++++++++++++++++++ backend/package.json | 2 + .../migration.sql | 11 +++ backend/prisma/schema.prisma | 16 +++- backend/src/app.js | 3 + backend/src/config/passport.js | 82 ++++++++++++++++ backend/src/controllers/auth.controller.js | 60 ++++++++++++ backend/src/routes/auth.routes.js | 54 ++++++++++- frontend/src/App.jsx | 2 + frontend/src/pages/AuthCallback.jsx | 68 +++++++++++++ frontend/src/pages/Connexion.jsx | 13 ++- frontend/src/pages/Inscription.jsx | 11 ++- 13 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql create mode 100644 backend/src/config/passport.js create mode 100644 frontend/src/pages/AuthCallback.jsx diff --git a/backend/.env.example b/backend/.env.example index 3134cbe..85ade36 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,11 @@ MAIL_PORT="587" MAIL_USER="your_email@gmail.com" MAIL_PASS="your_app_password" MAIL_FROM="Thé Tip Top " + +# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton +# correspondant : la route répond 503 au lieu de faire planter le serveur. +# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +FACEBOOK_APP_ID="" +FACEBOOK_APP_SECRET="" diff --git a/backend/package-lock.json b/backend/package-lock.json index d1c0f8d..7e007c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,8 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -2174,6 +2176,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -5598,6 +5609,12 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5758,6 +5775,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5819,6 +5894,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -6765,6 +6845,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6857,6 +6943,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 4e7c48b..a1f3b3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,8 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql new file mode 100644 index 0000000..1aa9d93 --- /dev/null +++ b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql @@ -0,0 +1,11 @@ +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT, +ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL', +ADD COLUMN "providerId" TEXT, +ALTER COLUMN "password" DROP NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7ab6470..89737cd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,12 +24,20 @@ enum ClaimStatus { CANCELLED } +enum AuthProvider { + LOCAL + GOOGLE + FACEBOOK +} + model User { id Int @id @default(autoincrement()) firstname String lastname String email String @unique - password String + // Nul pour les comptes créés via un provider externe (Google, Facebook) : + // ces utilisateurs n'ont jamais choisi de mot de passe chez nous. + password String? role Role @default(CLIENT) birthdate DateTime? gender String? @@ -43,10 +51,16 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + provider AuthProvider @default(LOCAL) + providerId String? + avatarUrl String? + tickets Ticket[] participations Participation[] grandPrizeWinner GrandPrizeWinner? claimsValidated Claim[] @relation("EmployeeClaims") + + @@unique([provider, providerId]) } model Lot { diff --git a/backend/src/app.js b/backend/src/app.js index 856e21e..d0c5d3d 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -11,6 +11,7 @@ const adminRoutes = require("./routes/admin.routes"); const swaggerUi = require("swagger-ui-express"); const swaggerSpec = require("./docs/swagger"); +const { passport } = require("./config/passport"); const app = express(); @@ -25,6 +26,8 @@ app.use((req, res, next) => { }); app.use(cors({ origin: allowedOrigins })); app.use(express.json()); +// OAuth stateless : initialize() suffit, aucune session n'est ouverte. +app.use(passport.initialize()); // Anti-bruteforce sur les routes d'authentification uniquement : 20 // tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js new file mode 100644 index 0000000..91fc6fb --- /dev/null +++ b/backend/src/config/passport.js @@ -0,0 +1,82 @@ +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth20").Strategy; +const prisma = require("./prisma"); + +// Authentification stateless : le callback OAuth émet directement notre JWT, +// donc pas de session Express ni de serialize/deserialize à brancher. + +const googleEnabled = + Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET); + +if (googleEnabled) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`, + scope: ["profile", "email"], + }, + async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + + if (!email) { + return done(null, false, { + message: "Aucune adresse email n'est associée à ce compte Google.", + }); + } + + // 1. Compte déjà lié à ce compte Google : connexion directe. + const byProvider = await prisma.user.findUnique({ + where: { + provider_providerId: { provider: "GOOGLE", providerId: profile.id }, + }, + }); + + if (byProvider) return done(null, byProvider); + + // 2. Un compte existe avec le même email : on le rattache à Google. + // L'email étant déjà validé par Google, on en profite pour lever + // la vérification si elle était en attente. + const byEmail = await prisma.user.findUnique({ where: { email } }); + + if (byEmail) { + const linked = await prisma.user.update({ + where: { id: byEmail.id }, + data: { + provider: "GOOGLE", + providerId: profile.id, + isEmailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, + avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null, + }, + }); + return done(null, linked); + } + + // 3. Première connexion : création du compte. + const created = await prisma.user.create({ + data: { + firstname: profile.name?.givenName || profile.displayName || "Utilisateur", + lastname: profile.name?.familyName || "", + email, + password: null, + provider: "GOOGLE", + providerId: profile.id, + avatarUrl: profile.photos?.[0]?.value || null, + isEmailVerified: true, + }, + }); + + return done(null, created); + } catch (error) { + return done(error); + } + } + ) + ); +} + +module.exports = { passport, googleEnabled }; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 4810fd6..bc78912 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -156,6 +156,14 @@ const login = async (req, res) => { }); } + // Compte créé via un provider externe : aucun mot de passe à comparer. + if (!user.password) { + const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google"; + return res.status(401).json({ + message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`, + }); + } + const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { @@ -354,6 +362,50 @@ const verifyCode = async (req, res) => { } }; +// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet +// le même JWT que la connexion classique et on renvoie le navigateur vers le +// front, qui récupère le token dans l'URL. +const oauthCallback = (req, res) => { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + try { + const user = req.user; + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Connexion externe refusée." + )}` + ); + } + + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: "24h" } + ); + + const payload = Buffer.from( + JSON.stringify({ + id: user.id, + firstname: user.firstname, + lastname: user.lastname, + email: user.email, + role: user.role, + }) + ).toString("base64"); + + return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); + } catch (error) { + console.error("Erreur oauthCallback:", error); + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Erreur serveur lors de la connexion externe." + )}` + ); + } +}; + const me = async (req, res) => { return res.json({ user: req.user, @@ -415,6 +467,13 @@ const changePassword = async (req, res) => { const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user.password) { + return res.status(400).json({ + message: + "Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.", + }); + } + const isValid = await bcrypt.compare(currentPassword, user.password); if (!isValid) { return res.status(401).json({ message: "Mot de passe actuel incorrect." }); @@ -440,4 +499,5 @@ module.exports = { forgotPassword, changePassword, deleteAccount, + oauthCallback, }; \ No newline at end of file diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 6053cc4..c697f00 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,8 +1,9 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); +const { passport, googleEnabled } = require("../config/passport"); const router = express.Router(); @@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); router.delete("/account", authMiddleware, deleteAccount); +/** + * @swagger + * /api/auth/google: + * get: + * summary: Démarre la connexion via Google (redirection OAuth 2.0) + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers l'écran de consentement Google. + * 503: + * description: Connexion Google non configurée sur ce serveur. + */ + +/** + * @swagger + * /api/auth/google/callback: + * get: + * summary: Retour de Google, émet le JWT et redirige vers le front + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers FRONTEND_URL/auth/callback avec le token. + */ +if (googleEnabled) { + router.get( + "/google", + passport.authenticate("google", { scope: ["profile", "email"], session: false }) + ); + + router.get( + "/google/callback", + passport.authenticate("google", { + session: false, + failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( + "Connexion Google refusée." + )}`, + }), + oauthCallback + ); +} else { + // Sans identifiants configurés, on répond explicitement plutôt que de + // laisser passport planter au démarrage. + const notConfigured = (req, res) => + res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." }); + + router.get("/google", notConfigured); + router.get("/google/callback", notConfigured); +} + module.exports = router; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index afcc658..8c1e936 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import ProtectedRoute from './components/ProtectedRoute.jsx' import CookieBanner from './components/CookieBanner.jsx' import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' +import AuthCallback from './pages/AuthCallback' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' @@ -29,6 +30,7 @@ export default function App() { } /> } /> } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx new file mode 100644 index 0000000..574b4ae --- /dev/null +++ b/frontend/src/pages/AuthCallback.jsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useSearchParams, Link } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici +// avec le JWT et le profil encodés dans l'URL. +export default function AuthCallback() { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const { login } = useAuth() + const [error, setError] = useState('') + // StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois. + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + handled.current = true + + const errorParam = searchParams.get('error') + if (errorParam) { + setError(errorParam) + return + } + + const token = searchParams.get('token') + const encodedUser = searchParams.get('user') + + if (!token || !encodedUser) { + setError('Réponse de connexion incomplète.') + return + } + + try { + const user = JSON.parse(atob(encodedUser)) + login(user, token) + + if (user.role === 'ADMIN') navigate('/admin', { replace: true }) + else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true }) + else navigate('/mes-gains', { replace: true }) + } catch { + setError('Réponse de connexion illisible.') + } + }, [searchParams, login, navigate]) + + if (error) { + return ( +
+
+ {error} +
+ + Retour à la connexion + +
+ ) + } + + return ( +
+

+ Connexion en cours… +

+
+ ) +} diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 3a9b1d2..e931393 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -15,6 +15,12 @@ export default function Connexion() { const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + // Navigation pleine page (et non fetch) : le flux OAuth passe par des + // redirections vers Google puis retour sur /auth/callback. + const handleGoogleLogin = () => { + window.location.href = '/api/auth/google' + } + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -123,6 +129,7 @@ export default function Connexion() {
-
From 8adf780da2cc3d5c6385bdcf83452dee263247ed Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Mon, 17 Aug 2026 00:25:27 +0200 Subject: [PATCH 3/6] Variables de connexion externe dans les environnements Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute FRONTEND_URL, absent des trois environnements alors que le callback OAuth et la liste d'origines CORS en dépendent : sans lui le backend retombait sur localhost:5173 et refusait le front déployé. Les identifiants Google et Facebook passent par substitution depuis l'environnement du pipeline, afin de ne pas committer de secret. Ignore aussi test-results/, généré par le reporter jest-junit ajouté par la chaîne CI. Co-Authored-By: Claude Opus 5 --- backend/.gitignore | 3 ++- backend/package-lock.json | 6 +++--- deploy/dev/docker-compose.yml | 8 ++++++++ deploy/preprod/docker-compose.yml | 8 ++++++++ deploy/prod/docker-compose.yml | 8 ++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 24361b6..ae19cfb 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,4 +2,5 @@ node_modules/ .env .prisma coverage/ -dist/ \ No newline at end of file +dist/ +test-results/ diff --git a/backend/package-lock.json b/backend/package-lock.json index 041d3d6..eef9418 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -610,9 +610,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, diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 8602afa..2cc738d 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -33,12 +33,20 @@ services: DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db JWT_SECRET: dev_secret_change_me APP_URL: http://192.168.1.23:5001 + FRONTEND_URL: http://192.168.1.23:5173 MAIL_HOST: smtp.gmail.com MAIL_PORT: 587 MAIL_USER: "" MAIL_PASS: "" MAIL_FROM: "The Tip Top " + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" + FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" + FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + command: > sh -c " npx prisma migrate deploy && diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index b61eef5..bd1384b 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -38,12 +38,20 @@ services: DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod JWT_SECRET: preprod_secret_change_me APP_URL: http://192.168.1.23:5002 + FRONTEND_URL: http://192.168.1.23:5174 MAIL_HOST: smtp.gmail.com MAIL_PORT: 587 MAIL_USER: "" MAIL_PASS: "" MAIL_FROM: "The Tip Top Preprod " + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" + FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" + FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + command: > sh -c " npx prisma migrate deploy && diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index beeda63..73145df 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -38,6 +38,7 @@ services: DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod JWT_SECRET: prod_secret_change_me APP_URL: http://192.168.1.23:5003 + FRONTEND_URL: http://192.168.1.23:5175 MAIL_HOST: smtp.gmail.com MAIL_PORT: 587 @@ -45,6 +46,13 @@ services: MAIL_PASS: "" MAIL_FROM: "The Tip Top " + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" + FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" + FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + command: > sh -c " npx prisma migrate deploy && From fa21d9af2a3469f0ba2b699449cfe9c763f931df Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Tue, 18 Aug 2026 15:06:51 +0200 Subject: [PATCH 4/6] ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter --- backend/src/controllers/admin.controller.js | 117 ++++++++++++- backend/src/controllers/auth.controller.js | 39 ++++- .../src/controllers/employee.controller.js | 10 +- backend/src/controllers/game.controller.js | 81 +++++++-- backend/src/routes/admin.routes.js | 44 ++++- backend/src/routes/game.routes.js | 7 +- backend/src/utils/gameWindow.js | 87 ++++++++-- backend/src/validations/admin.validation.js | 7 + backend/tests/drawNotification.test.js | 106 ++++++++++++ backend/tests/gainNotification.test.js | 113 ++++++++++++ backend/tests/gameFlow.test.js | 163 ++++++++++++++++++ backend/tests/gamePhases.test.js | 145 ++++++++++++++++ backend/tests/register.mail.test.js | 66 +++++++ frontend/src/pages/Admin.jsx | 116 ++++++++++++- frontend/src/pages/Connexion.jsx | 16 +- frontend/src/pages/Inscription.jsx | 63 ++++++- frontend/src/services/admin.js | 11 ++ frontend/src/services/auth.js | 22 ++- .../src/test/fonctionnel/Inscription.test.jsx | 57 +++++- 19 files changed, 1198 insertions(+), 72 deletions(-) create mode 100644 backend/tests/drawNotification.test.js create mode 100644 backend/tests/gainNotification.test.js create mode 100644 backend/tests/gameFlow.test.js create mode 100644 backend/tests/gamePhases.test.js create mode 100644 backend/tests/register.mail.test.js diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 5be295f..ecca247 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,7 +1,8 @@ const bcrypt = require("bcrypt"); const crypto = require("crypto"); const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { getGameWindow } = require("../utils/gameWindow"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -34,7 +35,6 @@ function computeAgeStats(users) { const getStats = async (req, res) => { try { - await expireOverdueClaims(); const gameWindow = await getGameWindow(); const [ @@ -80,9 +80,11 @@ const getStats = async (req, res) => { ticketsUsed, ticketsRemaining: ticketsTotal - ticketsUsed, participations, - claimsPending, + // L'expiration n'est pas écrite en base : une fois la fenêtre de retrait + // fermée, les PENDING restants comptent comme annulés. + claimsPending: gameWindow.isOpen ? claimsPending : 0, claimsDelivered, - claimsCancelled, + claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending, users, gameWindow, ageStats: computeAgeStats(clientsWithBirthdate), @@ -215,6 +217,31 @@ const createEmployee = async (req, res) => { const drawWinner = async (req, res) => { try { + // Le grand gagnant est tiré parmi tous les participants : le tirage n'a de + // sens qu'une fois la période de participation close. Avant l'ouverture, + // il n'existe aucun participant à départager. + const gameWindow = await getGameWindow(); + + if (!gameWindow.configured) { + return res.status(409).json({ + message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.", + }); + } + + if (!gameWindow.hasStarted) { + return res.status(409).json({ + message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`, + startDate: gameWindow.startDate, + }); + } + + if (gameWindow.isParticipationOpen) { + return res.status(409).json({ + message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`, + participationDeadline: gameWindow.participationDeadline, + }); + } + const existingWinner = await prisma.grandPrizeWinner.findFirst({ include: { user: { @@ -283,9 +310,41 @@ const drawWinner = async (req, res) => { }, }); + // Le tirage est un acte irréversible réalisé sous contrôle d'huissier : + // un email qui ne part pas ne doit surtout pas l'invalider. On signale + // simplement l'échec pour que l'administrateur prévienne autrement. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: savedWinner.user.email, + subject: "Vous avez gagné le gros lot Thé Tip Top !", + html: ` +
+

Félicitations ${savedWinner.user.firstname} !

+

Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu, + et c'est vous qui remportez le gros lot :

+
+ ${savedWinner.prize} +
+

Notre équipe vous recontactera très prochainement pour convenir des + modalités de remise de votre lot.

+

Merci de votre participation et à bientôt chez Thé Tip Top.

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email au grand gagnant:", mailError); + } + return res.status(201).json({ - message: "Tirage au sort effectué avec succès.", + message: emailSent + ? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email." + : "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.", totalEligibleParticipants: participants.length, + emailSent, winner: savedWinner, }); } catch (error) { @@ -296,6 +355,52 @@ const drawWinner = async (req, res) => { } }; +// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont +// découlent les deux échéances. Une seule ligne de configuration existe, on +// la met donc à jour si elle est déjà présente. +const setGameSettings = async (req, res) => { + try { + const { startDate } = req.body; + const parsedStartDate = new Date(startDate); + + const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + + if (existing) { + await prisma.gameSettings.update({ + where: { id: existing.id }, + data: { startDate: parsedStartDate }, + }); + } else { + await prisma.gameSettings.create({ data: { startDate: parsedStartDate } }); + } + + const gameWindow = await getGameWindow(); + + return res.json({ + message: existing + ? "Dates du jeu-concours mises à jour." + : "Jeu-concours lancé avec succès.", + gameWindow, + }); + } catch (error) { + console.error("Erreur configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la configuration du jeu-concours.", + }); + } +}; + +const getGameSettings = async (req, res) => { + try { + return res.json({ gameWindow: await getGameWindow() }); + } catch (error) { + console.error("Erreur lecture configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la lecture de la configuration.", + }); + } +}; + const getGrandPrizeWinner = async (req, res) => { try { const winner = await prisma.grandPrizeWinner.findFirst({ @@ -329,4 +434,6 @@ module.exports = { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, }; \ No newline at end of file diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index bc78912..846d945 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -62,11 +62,17 @@ const register = async (req, res) => { }, }); - await transporter.sendMail({ - from: process.env.MAIL_FROM, - to: email, - subject: "Votre code de validation Thé Tip Top", - html: ` + // Le code de validation est le seul moyen d'activer le compte. Si l'envoi + // échoue, laisser l'utilisateur en base le bloquerait définitivement : il + // ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email + // déjà pris). On annule donc la création pour que la tentative reste + // rejouable. + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre code de validation Thé Tip Top", + html: `

Bienvenue ${firstname} !

Merci pour votre inscription sur Thé Tip Top.

@@ -77,7 +83,21 @@ const register = async (req, res) => {

Ce code expire dans 15 minutes.

`, - }); + }); + } catch (mailError) { + console.error("Erreur envoi du code de validation:", mailError); + + try { + await prisma.user.delete({ where: { id: user.id } }); + } catch (rollbackError) { + console.error("Echec de l'annulation de l'inscription:", rollbackError); + } + + return res.status(502).json({ + message: + "Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.", + }); + } return res.status(201).json({ message: "Inscription réussie. Un code de validation a été envoyé par email.", @@ -435,10 +455,13 @@ const deleteAccount = async (req, res) => { // 4. Supprimer les participations await tx.participation.deleteMany({ where: { userId } }); - // 5. Libérer les tickets utilisés par ce user + // 5. Détacher les tickets du compte sans les remettre en jeu : un code + // figure sur un ticket de caisse physique et n'est jouable qu'une + // fois. Le réinitialiser permettrait de rejouer indéfiniment le même + // code en supprimant puis recréant un compte. await tx.ticket.updateMany({ where: { userId }, - data: { userId: null, isUsed: false, usedAt: null }, + data: { userId: null }, }); // 6. Supprimer l'utilisateur diff --git a/backend/src/controllers/employee.controller.js b/backend/src/controllers/employee.controller.js index 7dff989..33d8468 100644 --- a/backend/src/controllers/employee.controller.js +++ b/backend/src/controllers/employee.controller.js @@ -1,11 +1,11 @@ const prisma = require("../config/prisma"); -const { expireOverdueClaims } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); const getGainByTicketCode = async (req, res) => { try { const { code } = req.params; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const ticket = await prisma.ticket.findUnique({ where: { code: code.toUpperCase() }, @@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => { }, claim: { id: ticket.participation.claim.id, - status: ticket.participation.claim.status, + status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow), claimedAt: ticket.participation.claim.claimedAt, }, }); @@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => { const { id } = req.params; const employeeId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const claim = await prisma.claim.findUnique({ where: { id: Number(id) }, @@ -87,7 +87,7 @@ const deliverClaim = async (req, res) => { }); } - if (claim.status === "CANCELLED") { + if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") { return res.status(400).json({ message: "Ce gain est expiré et ne peut plus être remis.", }); diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 31f3345..9725af2 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -1,5 +1,13 @@ const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, +} = require("../utils/gameWindow"); + +// Levée par la transaction quand le ticket a été consommé entre-temps. +class TicketDejaUtiliseError extends Error {} const participate = async (req, res) => { try { @@ -7,9 +15,13 @@ const participate = async (req, res) => { const userId = req.user.id; const gameWindow = await getGameWindow(); - if (!gameWindow.isOpen) { + + // Un code n'est jouable que pendant les 30 jours de jeu : ni avant + // l'ouverture, ni pendant la prolongation réservée aux retraits. + if (!gameWindow.isParticipationOpen) { return res.status(410).json({ - message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.", + message: participationClosedMessage(gameWindow), + phase: gameWindow.phase, }); } @@ -33,16 +45,24 @@ const participate = async (req, res) => { } const result = await prisma.$transaction(async (tx) => { - const updatedTicket = await tx.ticket.update({ - where: { id: ticket.id }, + // Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées + // peuvent le franchir ensemble. On repasse donc par un update + // conditionnel, atomique, qui ne touche le ticket que s'il est encore + // libre — le perdant de la course obtient 0 ligne modifiée. + const { count } = await tx.ticket.updateMany({ + where: { id: ticket.id, isUsed: false }, data: { isUsed: true, usedAt: new Date(), userId, }, - include: { - lot: true, - }, + }); + + if (count === 0) throw new TicketDejaUtiliseError(); + + const updatedTicket = await tx.ticket.findUnique({ + where: { id: ticket.id }, + include: { lot: true }, }); const participation = await tx.participation.create({ @@ -67,8 +87,41 @@ const participate = async (req, res) => { }; }); + // La participation est déjà enregistrée et consultable dans « Mes gains » : + // un email qui ne part pas ne doit pas l'annuler ni consommer le ticket + // pour rien. On se contente de signaler l'échec. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: req.user.email, + subject: `Vous avez gagné : ${result.ticket.lot.name} !`, + html: ` +
+

Bravo ${req.user.firstname} !

+

Votre code ${result.ticket.code} est gagnant. Vous remportez :

+
+ ${result.ticket.lot.name} +
+ ${result.ticket.lot.description ? `

${result.ticket.lot.description}

` : ""} +

Présentez-vous en boutique avec ce code pour retirer votre lot${ + gameWindow.claimDeadline + ? `, avant le ${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}` + : "" + }.

+

Retrouvez à tout moment vos gains dans votre espace « Mes gains ».

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email de gain:", mailError); + } + return res.status(201).json({ message: "Participation enregistrée avec succès.", + emailSent, gain: { name: result.ticket.lot.name, description: result.ticket.lot.description, @@ -80,6 +133,12 @@ const participate = async (req, res) => { }, }); } catch (error) { + if (error instanceof TicketDejaUtiliseError) { + return res.status(409).json({ + message: "Ce code a déjà été utilisé.", + }); + } + console.error("Erreur participation:", error); return res.status(500).json({ message: "Erreur serveur lors de la participation.", @@ -91,7 +150,7 @@ const myGains = async (req, res) => { try { const userId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const participations = await prisma.participation.findMany({ where: { userId }, @@ -112,7 +171,9 @@ const myGains = async (req, res) => { lot: participation.lot.name, description: participation.lot.description, value: participation.lot.value, - claimStatus: participation.claim?.status, + claimStatus: participation.claim + ? effectiveClaimStatus(participation.claim.status, gameWindow) + : undefined, wonAt: participation.createdAt, })), }); diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index e5ee7cf..acd0e54 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -6,12 +6,14 @@ const { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, } = require("../controllers/admin.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); -const { createEmployeeSchema } = require("../validations/admin.validation"); +const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation"); const router = express.Router(); @@ -109,4 +111,44 @@ router.post("/draw", drawWinner); */ router.get("/draw", getGrandPrizeWinner); +/** + * @swagger + * /api/admin/game-settings: + * get: + * summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Configuration courante du jeu-concours + * put: + * summary: Lance le jeu-concours ou corrige sa date de début + * tags: + * - Admin + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - startDate + * properties: + * startDate: + * type: string + * format: date-time + * example: 2026-09-01T00:00:00.000Z + * responses: + * 200: + * description: Jeu-concours lancé ou mis à jour + * 400: + * description: Date de début invalide + */ +router.get("/game-settings", getGameSettings); +router.put("/game-settings", validate(gameSettingsSchema), setGameSettings); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 26a5b03..69c4eaa 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,6 +1,7 @@ const express = require("express"); const { participate, myGains } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); +const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); const { participateSchema } = require("../validations/game.validation"); @@ -20,7 +21,9 @@ const router = express.Router(); * description: Participation enregistrée */ -router.post("/participate", authMiddleware, validate(participateSchema), participate); +// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue +// consommerait des tickets et fausserait les statistiques. +router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate); /** * @swagger @@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici * description: Liste des gains */ -router.get("/my-gains", authMiddleware, myGains); +router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index eaa34f1..65c19c1 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -1,23 +1,41 @@ const prisma = require("../config/prisma"); +// Le concours traverse cinq états, et non deux : confondre « pas encore +// ouvert » avec « terminé » produit des messages faux côté joueur et laisse +// passer le tirage au sort avant le début du jeu. +const PHASES = { + NOT_CONFIGURED: "NOT_CONFIGURED", + SCHEDULED: "SCHEDULED", + OPEN: "OPEN", + CLAIM_ONLY: "CLAIM_ONLY", + CLOSED: "CLOSED", +}; + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; -// Fenêtre pendant laquelle un joueur peut à la fois tester son code et -// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter -// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré -// comme non démarré et donc ouvert (comportement de dev par défaut). -async function getGameWindow() { +// Deux échéances distinctes à partir de startDate : +// - participationDeadline (30 j) : dernier moment pour tester un code ; +// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné. +// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu. +// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non +// configuré et donc ouvert (comportement de dev par défaut). +async function getGameWindow(now = new Date()) { const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + // Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas : + // rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut + // reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé. if (!settings) { return { configured: false, + phase: PHASES.NOT_CONFIGURED, startDate: null, participationDeadline: null, claimDeadline: null, - isOpen: true, + isParticipationOpen: false, + isOpen: false, }; } @@ -26,26 +44,57 @@ async function getGameWindow() { settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS ); + const hasStarted = now >= settings.startDate; + const isParticipationOpen = hasStarted && now <= participationDeadline; + const isOpen = hasStarted && now <= claimDeadline; + + let phase; + if (!hasStarted) phase = PHASES.SCHEDULED; + else if (isParticipationOpen) phase = PHASES.OPEN; + else if (isOpen) phase = PHASES.CLAIM_ONLY; + else phase = PHASES.CLOSED; + return { configured: true, + phase, startDate: settings.startDate, participationDeadline, claimDeadline, - isOpen: new Date() <= claimDeadline, + hasStarted, + isParticipationOpen, + isOpen, }; } -// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre -// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture -// de claims, ce qui suffit pour ce projet. -async function expireOverdueClaims() { - const window = await getGameWindow(); - if (window.isOpen) return; - - await prisma.claim.updateMany({ - where: { status: "PENDING" }, - data: { status: "CANCELLED" }, - }); +// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en +// base : un PENDING dont le délai de retrait est passé est présenté comme +// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée +// suffit donc à rétablir les gains, et aucune lecture ne modifie la base. +function effectiveClaimStatus(status, gameWindow) { + if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED"; + return status; } -module.exports = { getGameWindow, expireOverdueClaims }; +// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque +// phase a sa propre explication, avec la date utile quand elle existe. +function participationClosedMessage(gameWindow) { + switch (gameWindow.phase) { + case PHASES.NOT_CONFIGURED: + return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement."; + case PHASES.SCHEDULED: + return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`; + case PHASES.CLAIM_ONLY: + return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés."; + default: + return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot."; + } +} + +module.exports = { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, + PHASES, + GAME_DURATION_DAYS, + CLAIM_EXTENSION_DAYS, +}; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index 6ea9852..c6771bf 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({ gender: z.string().optional(), }); +const gameSettingsSchema = z.object({ + startDate: z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"), +}); + module.exports = { createEmployeeSchema, + gameSettingsSchema, }; \ No newline at end of file diff --git a/backend/tests/drawNotification.test.js b/backend/tests/drawNotification.test.js new file mode 100644 index 0000000..80c26f9 --- /dev/null +++ b/backend/tests/drawNotification.test.js @@ -0,0 +1,106 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const DAY = 86400000; +const PREFIX = "notif-"; +const CODE = "NOTIFTICK1"; +let ctx = {}; + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: CODE } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const jourJ = (d) => + prisma.gameSettings.update({ + where: { id: ctx.settingsId }, + data: { startDate: new Date(Date.now() - d * DAY) }, + }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.settingsId = row.id; + ctx.startDateOriginale = row.startDate; + + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } }); + ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tAdmin = tk(ctx.admin); + + await jourJ(1); + mailer.sendMail.mockResolvedValue(true); + await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201); + await jourJ(35); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Notification du grand gagnant", () => { + beforeEach(() => jest.clearAllMocks()); + + test("le gagnant recoit un email a son adresse", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201); + + expect(res.body.emailSent).toBe(true); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(ctx.client.email); + expect(envoi.subject).toMatch(/gros lot/i); + expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain("360"); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + }); + + test("un SMTP en panne n invalide pas le tirage", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + + expect(res.status).toBe(201); + expect(res.body.emailSent).toBe(false); + expect(res.body.message).toMatch(/contacter directement/); + + // Le tirage reste enregistre : c est un acte irreversible. + const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } }); + expect(enBase).not.toBeNull(); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + }); + + test("aucun email n est envoye si le tirage est refuse", async () => { + mailer.sendMail.mockResolvedValue(true); + await jourJ(1); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + + expect(res.status).toBe(409); + expect(mailer.sendMail).not.toHaveBeenCalled(); + + await jourJ(35); + }); +}); diff --git a/backend/tests/gainNotification.test.js b/backend/tests/gainNotification.test.js new file mode 100644 index 0000000..5e3bf99 --- /dev/null +++ b/backend/tests/gainNotification.test.js @@ -0,0 +1,113 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const DAY = 86400000; +const PREFIX = "gain-"; +const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"]; +let ctx = {}; + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const jourJ = (d) => + prisma.gameSettings.update({ + where: { id: ctx.settingsId }, + data: { startDate: new Date(Date.now() - d * DAY) }, + }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.settingsId = row.id; + ctx.startDateOriginale = row.startDate; + + ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tClient = tk(ctx.client); + + await jourJ(1); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Notification du gain a chaque participation", () => { + beforeEach(() => jest.clearAllMocks()); + + test("le joueur recoit un email decrivant son lot", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app) + .post("/api/game/participate") + .set("Authorization", "Bearer " + ctx.tClient) + .send({ code: CODES[0] }) + .expect(201); + + expect(res.body.emailSent).toBe(true); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(ctx.client.email); + expect(envoi.subject).toContain(ctx.lot.name); + expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain(CODES[0]); + expect(envoi.html).toContain(ctx.lot.name); + }); + + test("un SMTP en panne n annule pas la participation", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app) + .post("/api/game/participate") + .set("Authorization", "Bearer " + ctx.tClient) + .send({ code: CODES[1] }); + + expect(res.status).toBe(201); + expect(res.body.emailSent).toBe(false); + + // Le gain reste acquis et consultable. + const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } }); + expect(t.isUsed).toBe(true); + + const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200); + expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true); + }); + + test("aucun email si le code est refuse", async () => { + mailer.sendMail.mockResolvedValue(true); + + const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + expect(rejoue.status).toBe(409); + + const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" }); + expect(inconnu.status).toBe(404); + + await jourJ(45); + const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] }); + expect(horsDelai.status).toBe(410); + await jourJ(1); + + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/gameFlow.test.js b/backend/tests/gameFlow.test.js new file mode 100644 index 0000000..040b603 --- /dev/null +++ b/backend/tests/gameFlow.test.js @@ -0,0 +1,163 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" }; +const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"]; +const DAY = 86400000; +let ctx = {}; +const log = (...a) => console.log(" ", ...a); + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const setStart = (d) => + prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } }); + +beforeAll(async () => { + await cleanup(); + ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.orig = ctx.settings.startDate; + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } }); + const h = await bcrypt.hash("Password123", 10); + const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } }); + ctx.adm = await mk(emails.adm, "ADMIN"); + ctx.emp = await mk(emails.emp, "EMPLOYEE"); + ctx.a = await mk(emails.a, "CLIENT"); + ctx.b = await mk(emails.b, "CLIENT"); + const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + ctx.tadm = tk(ctx.adm); + ctx.temp = tk(ctx.emp); + ctx.ta = tk(ctx.a); + ctx.tb = tk(ctx.b); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("VERIFICATION DES 7 CORRECTIFS", () => { + test("#1 ADMIN peut lancer le jeu", async () => { + const futur = new Date(Date.now() + 5 * DAY).toISOString(); + const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur }); + log("PUT game-settings ->", r.status, r.body.message); + expect(r.status).toBe(200); + + const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200); + log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)"); + + const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" }); + log("date invalide ->", bad.status, bad.body.message); + expect(bad.status).toBe(400); + + const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur }); + log("client tente de lancer le jeu ->", nonAdmin.status); + expect(nonAdmin.status).toBe(403); + }); + + test("#2 participation refusee apres 30 jours", async () => { + await setStart(45); + const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] }); + log("jour 45 ->", r.status, r.body.message); + expect(r.status).toBe(410); + + await setStart(1); + const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] }); + log("jour 1 ->", ok.status, ok.body.message || "accepte"); + expect(ok.status).toBe(201); + ctx.claimId = ok.body.claim.id; + }); + + test("#3 expiration derivee et reversible", async () => { + await setStart(70); + const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200); + log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(",")); + expect(g1.body.gains[0].claimStatus).toBe("CANCELLED"); + + const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } }); + log("statut REEL en base :", enBase.status, "(non ecrase)"); + expect(enBase.status).toBe("PENDING"); + + await setStart(1); + const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200); + log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)"); + expect(g2.body.gains[0].claimStatus).toBe("PENDING"); + }); + + test("#4 tirage refuse pendant le jeu", async () => { + await setStart(1); + const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`); + log("tirage pendant le jeu ->", r.status, r.body.message); + expect(r.status).toBe(409); + + await setStart(35); + const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`); + log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : ""); + expect(r2.status).toBe(201); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } }); + await setStart(1); + }); + + test("#5 admin et employe ne peuvent plus jouer", async () => { + const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] }); + const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] }); + log("ADMIN ->", r1.status, r1.body.message); + log("EMPLOYE ->", r2.status, r2.body.message); + expect(r1.status).toBe(403); + expect(r2.status).toBe(403); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } }); + log("le ticket reste vierge :", t.isUsed === false); + expect(t.isUsed).toBe(false); + }); + + test("#6 suppression de compte ne remet pas le ticket en jeu", async () => { + await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201); + await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } }); + log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId); + expect(t.isUsed).toBe(true); + + const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] }); + log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message); + expect(rejoue.status).toBe(409); + }); + + test("#7 course sur un meme code", async () => { + const h = await bcrypt.hash("Password123", 10); + const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" }); + const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + const [r1, r2] = await Promise.all([ + request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }), + request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }), + ]); + + const codes = [r1.status, r2.status].sort(); + log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)"); + expect(codes).toEqual([201, 409]); + + const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } }); + log("participations pour ce ticket :", n); + expect(n).toBe(1); + }); +}); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js new file mode 100644 index 0000000..8f87ea0 --- /dev/null +++ b/backend/tests/gamePhases.test.js @@ -0,0 +1,145 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const DAY = 86400000; +const PREFIX = "r2-"; +const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"]; +let ctx = {}; +const log = (...a) => console.log(" ", ...a); +const titre = (t) => console.log("\n===== " + t + " ====="); + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +async function jourJ(offsetJours) { + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + const debut = new Date(Date.now() - offsetJours * DAY); + if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } }); + else await prisma.gameSettings.create({ data: { startDate: debut } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.startDateOriginale = row ? row.startDate : null; + + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } }); + ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tAdmin = tk(ctx.admin); + ctx.tClient = tk(ctx.client); +}); + +afterAll(async () => { + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + if (ctx.startDateOriginale) { + if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } }); + else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } }); + } + await cleanup(); + await prisma.$disconnect(); +}); + +describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { + + test("A. rien nest jouable avant tout lancement", async () => { + titre("A : aucune GameSettings en base"); + await prisma.gameSettings.deleteMany({}); + + const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200); + log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen); + expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED"); + + const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("un client tente de jouer ->", essai.status, essai.body.message); + expect(essai.status).toBe(410); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } }); + log("le ticket reste vierge :", t.isUsed === false); + expect(t.isUsed).toBe(false); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("admin tente le tirage ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(409); + expect(await prisma.grandPrizeWinner.count()).toBe(0); + }); + + test("B. jeu programme : message juste et tirage bloque", async () => { + titre("B : lancement programme a J+7"); + const futur = new Date(Date.now() + 7 * DAY).toISOString(); + const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur }); + log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase); + expect(r.body.gameWindow.phase).toBe("SCHEDULED"); + + const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("client joue avant ouverture ->", anticipe.status); + log("message :", JSON.stringify(anticipe.body.message)); + expect(anticipe.body.message).toMatch(/ouvrira le/); + expect(anticipe.body.message).not.toMatch(/terminée/); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("tirage avant ouverture ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(409); + expect(tirage.body.message).toMatch(/pas encore commencé/); + }); + + test("C. les quatre phases dun jeu configure", async () => { + titre("C : parcours des phases"); + + await jourJ(1); + let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("jour 1 | phase", w.phase, "| participate ->", ok.status); + expect(w.phase).toBe("OPEN"); + expect(ok.status).toBe(201); + ctx.claimId = ok.body.claim.id; + + await jourJ(35); + w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] }); + log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message)); + expect(w.phase).toBe("CLAIM_ONLY"); + expect(tard.body.message).toMatch(/participation est terminée/); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("tirage apres cloture ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(201); + + await jourJ(70); + w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] }); + log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message)); + expect(w.phase).toBe("CLOSED"); + expect(fini.body.message).toMatch(/jeu-concours est terminé/); + }); + + test("D. non-regression : le parcours nominal fonctionne toujours", async () => { + titre("D : verification du parcours de base"); + await jourJ(2); + const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200); + log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus))); + expect(g.body.gains[0].claimStatus).toBe("PENDING"); + + const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200); + log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations); + expect(stats.body.gameWindow.phase).toBe("OPEN"); + }); +}); diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js new file mode 100644 index 0000000..463821c --- /dev/null +++ b/backend/tests/register.mail.test.js @@ -0,0 +1,66 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ + sendMail: jest.fn(), +})); + +// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne +// touche qu'au compte qu'il crée lui-même, identifié par cet email. +const testEmail = "rollback-mail-test@test.com"; + +const payload = { + firstname: "Camille", + lastname: "Martin", + email: testEmail, + password: "Password123", + birthdate: "1990-05-14", + gender: "Femme", +}; + +beforeEach(async () => { + jest.clearAllMocks(); + await prisma.user.deleteMany({ where: { email: testEmail } }); +}); + +afterAll(async () => { + await prisma.user.deleteMany({ where: { email: testEmail } }); + await prisma.$disconnect(); +}); + +describe("Inscription - echec d'envoi du code de validation", () => { + test("repond 502 et ne laisse aucun compte orphelin", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app).post("/api/auth/register").send(payload); + + expect(res.statusCode).toBe(502); + + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + expect(user).toBeNull(); + }); + + test("l'email reste reutilisable apres un echec d'envoi", async () => { + mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible")); + const echec = await request(app).post("/api/auth/register").send(payload); + expect(echec.statusCode).toBe(502); + + mailer.sendMail.mockResolvedValue(true); + const succes = await request(app).post("/api/auth/register").send(payload); + + expect(succes.statusCode).toBe(201); + }); + + test("enregistre la date de naissance et le genre", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app).post("/api/auth/register").send(payload); + expect(res.statusCode).toBe(201); + + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + expect(user.gender).toBe("Femme"); + expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z"); + }); +}); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 4272e81..521c00a 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin' import { searchTicketApi, deliverClaimApi } from '../services/employee' import { useHandleAuthError } from '../hooks/useHandleAuthError' @@ -13,6 +13,32 @@ function StatCard({ label, value, sub }) { ) } +// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé » +// ne doit pas être confondu avec « retraits uniquement ». +const PHASE_BADGES = { + NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' }, + SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' }, + OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' }, + CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' }, + CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' }, +} + +const PHASE_PANELS = { + NOT_CONFIGURED: 'bg-amber-50 border border-amber-200', + SCHEDULED: 'bg-indigo-50 border border-indigo-200', + OPEN: 'bg-white', + CLAIM_ONLY: 'bg-white', + CLOSED: 'bg-red-50 border border-red-200', +} + +const PHASE_TITLES = { + NOT_CONFIGURED: 'Jeu-concours non lancé', + SCHEDULED: 'Jeu-concours programmé', + OPEN: 'Jeu-concours en cours', + CLAIM_ONLY: 'Participations closes, retraits en cours', + CLOSED: 'Jeu-concours terminé', +} + function formatDate(dateStr) { return new Date(dateStr).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric', @@ -31,6 +57,12 @@ export default function Admin() { const [error, setError] = useState('') const [showConfirm, setShowConfirm] = useState(false) + // Lancement du jeu-concours + const [startDateInput, setStartDateInput] = useState('') + const [settingsSaving, setSettingsSaving] = useState(false) + const [settingsError, setSettingsError] = useState('') + const [settingsOk, setSettingsOk] = useState('') + // Remise de lot const [ticketCode, setTicketCode] = useState('') const [ticketResult, setTicketResult] = useState(null) @@ -58,6 +90,25 @@ export default function Admin() { .finally(() => setLoading(false)) }, []) + const handleSaveGameSettings = async (e) => { + e.preventDefault() + setSettingsError('') + setSettingsOk('') + setSettingsSaving(true) + try { + // L'input datetime-local renvoie une heure locale sans fuseau : on la + // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté. + const data = await setGameSettingsApi(new Date(startDateInput).toISOString()) + setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev)) + setSettingsOk(data.message) + setStartDateInput('') + } catch (err) { + if (!handleAuthError(err)) setSettingsError(err.message) + } finally { + setSettingsSaving(false) + } + } + const handleDraw = async () => { setShowConfirm(false) setDrawing(true) @@ -164,21 +215,61 @@ export default function Admin() {

Tableau de bord Admin

{/* Fenêtre du jeu-concours */} - {stats?.gameWindow?.configured && ( -
+ {stats?.gameWindow && ( +

- Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'} -

-

- Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)} + {PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}

+ {stats.gameWindow.configured ? ( +

+ Début : {formatDate(stats.gameWindow.startDate)} +
+ Fin des participations : {formatDate(stats.gameWindow.participationDeadline)} +
+ Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)} +

+ ) : ( +

+ Choisissez une date de début pour ouvrir le jeu-concours. +

+ )}
- - {stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'} + + {PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
+ +
+
+ + setStartDateInput(e.target.value)} + required + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> +
+ +
+ +

+ Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots. +

+ + {settingsError &&

{settingsError}

} + {settingsOk &&

{settingsOk}

}
)} @@ -440,6 +531,13 @@ export default function Admin() { Tirage effectué le {formatDate(winner.winner.createdAt)}

)} + {/* Signalé uniquement au retour du tirage : le GET du gagnant ne + renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */} + {winner?.emailSent === false && ( +

+ L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus. +

+ )}
) : ( /* Pas encore de tirage */ diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index e931393..502b20d 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -132,9 +132,12 @@ export default function Connexion() { onClick={handleGoogleLogin} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors" > -
- G -
+ Google diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 0edfa67..86f0861 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom' import { registerApi, verifyCodeApi } from '../services/auth' import { useAuth } from '../context/AuthContext' +// Une naissance ne peut pas être dans le futur : borne haute du sélecteur. +const AUJOURD_HUI = new Date().toISOString().split('T')[0] + +// Valeurs alignées sur le tableau de bord admin, qui compte exactement +// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre". +const GENRES = ['Femme', 'Homme', 'Autre'] + export default function Inscription() { const navigate = useNavigate() const { login } = useAuth() @@ -10,8 +17,18 @@ export default function Inscription() { // Étape : 'form' | 'verify' const [step, setStep] = useState('form') const [email, setEmail] = useState('') - const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' }) + const [form, setForm] = useState({ + email: '', + lastName: '', + firstName: '', + password: '', + birthdate: '', + gender: '', + }) const [acceptedTerms, setAcceptedTerms] = useState(false) + // Consentement marketing : distinct des CGU et décoché par défaut, il doit + // rester un choix libre et non une condition d'inscription. + const [marketingOptIn, setMarketingOptIn] = useState(false) const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) @@ -40,6 +57,9 @@ export default function Inscription() { firstname: form.firstName, lastname: form.lastName, password: form.password, + birthdate: form.birthdate, + gender: form.gender, + marketingOptIn, }) setEmail(form.email) setStep('verify') @@ -181,6 +201,23 @@ export default function Inscription() { className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> +
+ + +
+ +
+ + +
+
@@ -206,6 +243,18 @@ export default function Inscription() { + +
diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 152d288..9efd60b 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -39,6 +39,17 @@ export async function getEmailingContactsApi() { return data } +export async function setGameSettingsApi(startDate) { + const res = await fetch('/api/admin/game-settings', { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ startDate }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status) + return data +} + export async function getWinnerApi() { const res = await fetch('/api/admin/draw', { headers: authHeaders() }) const data = await res.json() diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index eb55d8a..c115d90 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -17,11 +17,29 @@ export async function loginApi({ email, password }) { return data } -export async function registerApi({ email, firstname, lastname, password }) { +export async function registerApi({ + email, + firstname, + lastname, + password, + birthdate, + gender, + marketingOptIn, +}) { const res = await fetch(`${BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, firstname, lastname, password }), + // birthdate et gender sont facultatifs : on omet la clé plutôt que + // d'envoyer une chaîne vide, que l'API stockerait telle quelle. + body: JSON.stringify({ + email, + firstname, + lastname, + password, + birthdate: birthdate || undefined, + gender: gender || undefined, + marketingOptIn: marketingOptIn ?? undefined, + }), }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status) diff --git a/frontend/src/test/fonctionnel/Inscription.test.jsx b/frontend/src/test/fonctionnel/Inscription.test.jsx index 8f63c02..e774776 100644 --- a/frontend/src/test/fonctionnel/Inscription.test.jsx +++ b/frontend/src/test/fonctionnel/Inscription.test.jsx @@ -16,7 +16,7 @@ async function remplirEtSoumettre() { await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') - await userEvent.click(screen.getByRole('checkbox')) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) } @@ -49,7 +49,7 @@ describe('Inscription', () => { const bouton = screen.getByRole('button', { name: /s'inscrire/i }) expect(bouton).toBeDisabled() - await userEvent.click(screen.getByRole('checkbox')) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) expect(bouton).toBeEnabled() }) @@ -64,11 +64,64 @@ describe('Inscription', () => { firstname: 'Jean', lastname: 'Dupont', password: 'MotDePasse123!', + birthdate: '', + gender: '', + marketingOptIn: false, }) expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument() expect(screen.getByText('jean@example.com')).toBeInTheDocument() }) + it("transmet la date de naissance et le genre saisis", async () => { + registerApi.mockResolvedValue({ message: 'ok' }) + + renderWithProviders(, { route: '/inscription' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') + await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') + await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') + await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') + await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(registerApi).toHaveBeenCalledWith( + expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' }) + ) + }) + + it("transmet le consentement marketing quand il est coché", async () => { + registerApi.mockResolvedValue({ message: 'ok' }) + + renderWithProviders(, { route: '/inscription' }) + + const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i }) + expect(marketing).not.toBeChecked() + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') + await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') + await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.click(marketing) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(registerApi).toHaveBeenCalledWith( + expect.objectContaining({ marketingOptIn: true }) + ) + }) + + it("n'exige pas le consentement marketing pour s'inscrire", async () => { + renderWithProviders(, { route: '/inscription' }) + + const bouton = screen.getByRole('button', { name: /s'inscrire/i }) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + + expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked() + expect(bouton).toBeEnabled() + }) + it("affiche l'erreur de l'API si l'inscription échoue", async () => { registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.')) From e895325254013e1f90fc261a20c88f3b1f6ea010 Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Wed, 19 Aug 2026 21:56:18 +0200 Subject: [PATCH 5/6] refont de la connexion par google et validation de lenvoie de mail a l'utilisater --- backend/.env.example | 6 +- backend/src/config/passport.js | 54 ++++++++ backend/src/controllers/auth.controller.js | 15 ++- backend/src/middlewares/auth.middleware.js | 8 +- backend/tests/googlePassword.test.js | 142 +++++++++++++++++++++ frontend/src/App.jsx | 2 + frontend/src/pages/DefinirMotDePasse.jsx | 133 +++++++++++++++++++ frontend/src/pages/Profile.jsx | 69 +++++++++- frontend/src/services/auth.js | 32 +++++ 9 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 backend/tests/googlePassword.test.js create mode 100644 frontend/src/pages/DefinirMotDePasse.jsx diff --git a/backend/.env.example b/backend/.env.example index 85ade36..47c457f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,9 +8,9 @@ APP_URL="http://localhost:5000" FRONTEND_URL="http://localhost:5173" MAIL_HOST="smtp.gmail.com" MAIL_PORT="587" -MAIL_USER="your_email@gmail.com" -MAIL_PASS="your_app_password" -MAIL_FROM="Thé Tip Top " +MAIL_USER=abayomiandil62@gmail.com +MAIL_PASS=dumqamoupdrliygf +MAIL_FROM="Thé Tip Top " # Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton # correspondant : la route répond 503 au lieu de faire planter le serveur. diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index 91fc6fb..1e9a434 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,6 +1,52 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const crypto = require("crypto"); const prisma = require("./prisma"); +const transporter = require("./mailer"); + +// Un compte créé via un provider externe n'a pas de mot de passe. On lui en +// propose un dès l'inscription, via le même mécanisme de jeton que la +// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de +// Google pour se connecter, et le formulaire de changement de son profil lui +// reste inaccessible. +const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h + +async function sendPasswordSetupEmail(user) { + const token = crypto.randomBytes(32).toString("hex"); + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken: token, + resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS), + }, + }); + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`; + + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: user.email, + subject: "Définissez votre mot de passe Thé Tip Top", + html: ` +
+

Bienvenue ${user.firstname} !

+

Votre compte a été créé avec Google. Vous pouvez continuer à vous + connecter de cette façon, ou définir un mot de passe pour accéder + également à votre compte sans passer par Google.

+

+ + Définir mon mot de passe + +

+

Ce lien expire dans 24 heures. Si vous + ne souhaitez pas de mot de passe, ignorez simplement cet email : votre + connexion Google continuera de fonctionner.

+
+ `, + }); +} // Authentification stateless : le callback OAuth émet directement notre JWT, // donc pas de session Express ni de serialize/deserialize à brancher. @@ -70,6 +116,14 @@ if (googleEnabled) { }, }); + // Un envoi qui échoue ne doit pas bloquer la connexion : le compte + // est valide, et « mot de passe oublié » permet de rattraper. + try { + await sendPasswordSetupEmail(created); + } catch (mailError) { + console.error("Erreur envoi email de definition du mot de passe:", mailError); + } + return done(null, created); } catch (error) { return done(error); diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 846d945..cef2552 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -252,7 +252,10 @@ const forgotPassword = async (req, res) => { }, }); - const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`; + // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : + // un clic depuis un email est une requête GET, qui tombait en 404. + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -283,7 +286,7 @@ const forgotPassword = async (req, res) => { const resetPassword = async (req, res) => { try { const { token } = req.params; - const { password } = req.body; + const { password, confirmPassword } = req.body; if (!password || password.length < 8) { return res.status(400).json({ @@ -291,6 +294,14 @@ const resetPassword = async (req, res) => { }); } + // La confirmation est vérifiée côté serveur aussi : le contrôle du + // navigateur ne protège pas un appel direct à l'API. + if (confirmPassword !== undefined && password !== confirmPassword) { + return res.status(400).json({ + message: "Les deux mots de passe ne correspondent pas.", + }); + } + const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index e3d07b4..072501e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => { lastname: true, email: true, role: true, + provider: true, + password: true, }, }); @@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => { }); } - req.user = user; + // req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par + // un simple booléen, qui suffit au profil pour proposer « définir » ou + // « modifier » son mot de passe. + const { password, ...rest } = user; + req.user = { ...rest, hasPassword: Boolean(password) }; next(); } catch (error) { return res.status(401).json({ diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js new file mode 100644 index 0000000..b0ceb71 --- /dev/null +++ b/backend/tests/googlePassword.test.js @@ -0,0 +1,142 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passport, googleEnabled } = require("../src/config/passport"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const EMAIL = "google-pwd-test@test.local"; +const PROFILE = { + id: "sim-google-id-123456", + displayName: "Chloe Martin", + name: { givenName: "Chloe", familyName: "Martin" }, + emails: [{ value: EMAIL }], + photos: [{ value: "https://example.test/avatar.png" }], +}; + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: EMAIL } }); +} + +// Rejoue le callback de la strategie Google sans sortir sur le reseau. +function verifierProfilGoogle() { + const strategy = passport._strategy("google"); + return new Promise((resolve, reject) => { + strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => { + if (err) reject(err); + else resolve(user); + }); + }); +} + +beforeAll(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +const decrire = googleEnabled ? describe : describe.skip; + +decrire("Inscription Google et definition du mot de passe", () => { + beforeEach(() => jest.clearAllMocks()); + + test("un compte Google est cree sans mot de passe et recoit un lien", async () => { + mailer.sendMail.mockResolvedValue(true); + + const user = await verifierProfilGoogle(); + + expect(user.provider).toBe("GOOGLE"); + expect(user.password).toBeNull(); + expect(user.isEmailVerified).toBe(true); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(EMAIL); + expect(envoi.subject).toMatch(/mot de passe/i); + + // Le lien doit viser le formulaire du front, pas la route POST de l API. + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`); + + const enBase = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(enBase.resetPasswordToken).toHaveLength(64); + expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now()); + ctxToken = enBase.resetPasswordToken; + }); + + test("le profil signale que le compte n a pas de mot de passe", async () => { + const jwt = require("jsonwebtoken"); + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200); + + expect(res.body.user.hasPassword).toBe(false); + expect(res.body.user.provider).toBe("GOOGLE"); + // Le hash ne doit jamais sortir de l API. + expect(res.body.user.password).toBeUndefined(); + }); + + test("la connexion par mot de passe est refusee tant qu il n en existe pas", async () => { + const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" }); + expect(res.status).toBe(401); + expect(res.body.message).toMatch(/connexion Google/); + }); + + test("une confirmation qui ne correspond pas est refusee", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/ne correspondent pas/); + + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(u.password).toBeNull(); + }); + + test("un mot de passe trop court est refuse", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "court", confirmPassword: "court" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/8 caractères/); + }); + + test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => { + const ok = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }) + .expect(200); + expect(ok.body.message).toMatch(/réinitialisé/); + + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200); + expect(login.body.token).toBeDefined(); + + // Le jeton a bien ete consomme. + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(u.resetPasswordToken).toBeNull(); + + const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200); + expect(me.body.user.hasPassword).toBe(true); + + const change = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", "Bearer " + login.body.token) + .send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" }); + expect(change.status).toBe(200); + }); + + test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => { + mailer.sendMail.mockResolvedValue(true); + + const user = await verifierProfilGoogle(); + + expect(user.email).toBe(EMAIL); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); +}); + +let ctxToken; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8c1e936..ff45923 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import CookieBanner from './components/CookieBanner.jsx' import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import AuthCallback from './pages/AuthCallback' +import DefinirMotDePasse from './pages/DefinirMotDePasse' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/pages/DefinirMotDePasse.jsx b/frontend/src/pages/DefinirMotDePasse.jsx new file mode 100644 index 0000000..fb5a4a0 --- /dev/null +++ b/frontend/src/pages/DefinirMotDePasse.jsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { resetPasswordApi } from '../services/auth' + +// Sert les deux parcours qui passent par un jeton envoyé par email : +// la définition initiale du mot de passe après une inscription Google, +// et la réinitialisation classique depuis « mot de passe oublié ». +export default function DefinirMotDePasse() { + const { token } = useParams() + const navigate = useNavigate() + + const [form, setForm] = useState({ password: '', confirmPassword: '' }) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [loading, setLoading] = useState(false) + + const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + if (form.password.length < 8) { + setError('Le mot de passe doit contenir au moins 8 caractères.') + return + } + if (form.password !== form.confirmPassword) { + setError('Les deux mots de passe ne correspondent pas.') + return + } + + setLoading(true) + try { + await resetPasswordApi({ token, ...form }) + setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.') + setTimeout(() => navigate('/connexion'), 2500) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+ {success} +
+ + Aller à la connexion + +
+ ) + } + + return ( +
+
+

Mot de passe

+

+ Choisissez un mot de passe pour accéder à votre compte sans passer par Google. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ + + + +
+ +

+ + Retour à la connexion + +

+
+ ) +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 612d848..7d85f1e 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import UserNav from '../components/UserNav' -import { changePasswordApi, deleteAccountApi } from '../services/auth' +import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth' import { useHandleAuthError } from '../hooks/useHandleAuthError' import { useAuth } from '../context/AuthContext' @@ -22,6 +22,35 @@ export default function Profile() { const [pwError, setPwError] = useState('') const [pwSuccess, setPwSuccess] = useState(false) + // Un compte créé via Google peut ne pas avoir de mot de passe : le + // formulaire de modification n'aurait alors aucun sens. L'information vient + // de l'API, car le profil stocké en localStorage ne la contient pas. + const [hasPassword, setHasPassword] = useState(null) + const [linkSending, setLinkSending] = useState(false) + const [linkSent, setLinkSent] = useState(false) + const [linkError, setLinkError] = useState('') + + useEffect(() => { + meApi() + .then((data) => setHasPassword(data.user.hasPassword)) + .catch((e) => { + if (!handleAuthError(e)) setHasPassword(true) + }) + }, []) + + const handleSendSetupLink = async () => { + setLinkError('') + setLinkSending(true) + try { + await forgotPasswordApi(user.email) + setLinkSent(true) + } catch (e) { + if (!handleAuthError(e)) setLinkError(e.message) + } finally { + setLinkSending(false) + } + } + const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value }) const handlePwSubmit = async (e) => { @@ -97,9 +126,37 @@ export default function Profile() { {/* Section changement de mot de passe */}
-

Changer mon mot de passe

+

+ {hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'} +

- {pwSuccess && ( + {hasPassword === false && ( +
+

+ Votre compte a été créé avec Google et n'a pas encore de mot de passe. + Définissez-en un pour pouvoir aussi vous connecter avec votre email. +

+ + {linkSent ? ( +

+ Lien envoyé à {user.email}. Consultez votre boîte mail. +

+ ) : ( + + )} + + {linkError &&

{linkError}

} +
+ )} + + {hasPassword !== false && pwSuccess && (
@@ -108,12 +165,13 @@ export default function Profile() {
)} - {pwError && ( + {hasPassword !== false && pwError && (
{pwError}
)} + {hasPassword !== false && (
{[ { name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' }, @@ -153,6 +211,7 @@ export default function Profile() { {pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
+ )}
) diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index c115d90..5103caa 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -57,6 +57,38 @@ export async function verifyCodeApi({ email, code }) { return data } +export async function forgotPasswordApi(email) { + const res = await fetch(`${BASE}/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) + return data +} + +export async function resetPasswordApi({ token, password, confirmPassword }) { + const res = await fetch(`${BASE}/reset-password/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, confirmPassword }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status) + return data +} + +export async function meApi() { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/me`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status) + return data +} + export async function deleteAccountApi() { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/account`, { From 2cf399d6d0ecf1a19a125ccb86ac4101cad0e84a Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Thu, 20 Aug 2026 21:49:36 +0200 Subject: [PATCH 6/6] correction des bugue front end --- frontend/src/components/Footer.jsx | 4 ++-- frontend/src/components/Header.jsx | 14 +++++++------- frontend/src/components/UserNav.jsx | 7 +++++-- frontend/src/context/AuthContext.jsx | 2 ++ frontend/src/pages/Admin.jsx | 21 ++++++++++++--------- frontend/src/pages/Connexion.jsx | 10 +++++----- frontend/src/pages/Contact.jsx | 2 +- frontend/src/pages/DefinirMotDePasse.jsx | 6 +++--- frontend/src/pages/Employe.jsx | 6 +++--- frontend/src/pages/Inscription.jsx | 20 ++++++++++---------- frontend/src/pages/MesGains.jsx | 10 +++++----- frontend/src/pages/NotFound.jsx | 2 +- frontend/src/pages/Perdu.jsx | 4 ++-- frontend/src/pages/Profile.jsx | 12 ++++++------ frontend/src/pages/SaisirCodeUnique.jsx | 13 +++++-------- frontend/src/pages/Sitemap.jsx | 2 +- 16 files changed, 70 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index e16923d..c12c9f8 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -20,7 +20,7 @@ export default function Footer() { {/* Partie principale */} -
+
{/* Logo */} @@ -185,7 +185,7 @@ export default function Footer() { 01 84 80 20 34 - + contact@thetiptop.fr diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 0862d4f..ae0c831 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -9,7 +9,7 @@ export default function Header() { const navigate = useNavigate() const { user, logout } = useAuth() const isLoggedIn = !!user - const logoContent = Logo de Thé Tip Top + const logoContent = Logo de Thé Tip Top const handleLogout = () => { logout() @@ -40,12 +40,12 @@ export default function Header() { )} {/* Nav desktop */} -
) : (
- + Connexion - + Inscription
diff --git a/frontend/src/components/UserNav.jsx b/frontend/src/components/UserNav.jsx index 6582af3..fac7c82 100644 --- a/frontend/src/components/UserNav.jsx +++ b/frontend/src/components/UserNav.jsx @@ -6,6 +6,7 @@ export default function UserNav({ active }) { key: 'code', to: '/saisir-code-unique', label: 'Saisir un code', + shortLabel: 'Code', icon: ( @@ -16,6 +17,7 @@ export default function UserNav({ active }) { key: 'gains', to: '/mes-gains', label: 'Mes gains', + shortLabel: 'Mes gains', icon: ( @@ -26,6 +28,7 @@ export default function UserNav({ active }) { key: 'profile', to: '/profile', label: 'Profil', + shortLabel: 'Profil', icon: ( @@ -43,12 +46,12 @@ export default function UserNav({ active }) { className={`flex-1 flex items-center justify-center gap-1.5 py-3 text-xs sm:text-sm font-medium transition-colors border-b-2 ${ active === tab.key ? 'text-[#006B6B] border-[#006B6B]' - : 'text-gray-400 border-transparent hover:text-gray-600' + : 'text-gray-500 border-transparent hover:text-gray-600' }`} > {tab.icon} {tab.label} - {tab.label.split(' ')[0]} + {tab.shortLabel} ))}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 552cb66..5035bb7 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components */ + import { createContext, useContext, useState } from 'react' const AuthContext = createContext(null) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 521c00a..7ff6674 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -8,7 +8,7 @@ function StatCard({ label, value, sub }) {

{value ?? '—'}

{label}

- {sub &&

{sub}

} + {sub &&

{sub}

}
) } @@ -145,6 +145,9 @@ export default function Admin() { const csv = [header, ...rows] .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';')) .join('\n') + // Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le + // CSV en ANSI et casse tous les accents. Ne pas le supprimer. + // eslint-disable-next-line no-irregular-whitespace const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') @@ -196,7 +199,7 @@ export default function Admin() { if (loading) return (
-

Chargement...

+

Chargement...

) @@ -264,7 +267,7 @@ export default function Admin() { -

+

Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.

@@ -393,7 +396,7 @@ export default function Admin() { {emailingContacts.length === 0 && ( - + Aucun contact n'a accepté l'emailing pour le moment @@ -428,7 +431,7 @@ export default function Admin() { onChange={(e) => setTicketCode(e.target.value.toUpperCase())} placeholder="Code ticket (10 caractères)" maxLength={10} - className="flex-1 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]" + className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]" />