diff --git a/Jenkinsfile b/Jenkinsfile index e2d9134..2791fcb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -332,10 +332,11 @@ stage('SonarQube Quality Gate') { backend docker build \ - --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ - --build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \ - -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ - frontend + --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ + --build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \ + --build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \ + -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ + frontend ''' } } diff --git a/backend/.env.example b/backend/.env.example index 47c457f..e434a0f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" FACEBOOK_APP_ID="" FACEBOOK_APP_SECRET="" + +# Cloudflare Turnstile (anti-robot sur inscription et connexion). Laisser vide +# désactive la vérification. La clé publique va dans frontend/.env +# (VITE_TURNSTILE_SITE_KEY). +TURNSTILE_SECRET_KEY="" diff --git a/backend/.gitignore b/backend/.gitignore index ae19cfb..d651b47 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,3 +4,15 @@ node_modules/ coverage/ dist/ test-results/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js index b803d50..bbaefc1 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -3,6 +3,8 @@ module.exports = { testMatch: ["**/tests/**/*.test.js"], + setupFiles: ["/tests/setupEnv.js"], + clearMocks: true, testTimeout: 10000, diff --git a/backend/package-lock.json b/backend/package-lock.json index 6f13beb..859f24a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", @@ -5845,6 +5846,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-facebook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", + "integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index a637c62..b9569c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", diff --git a/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql new file mode 100644 index 0000000..c25e8e3 --- /dev/null +++ b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql new file mode 100644 index 0000000..d8c051e --- /dev/null +++ b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql @@ -0,0 +1,14 @@ +-- Les emails sont désormais stockés et recherchés en minuscules, sans espaces +-- autour (voir src/validations/email.validation.js). On aligne les comptes +-- existants, sauf ceux dont la version normalisée appartient déjà à un autre +-- compte : les modifier violerait la contrainte d'unicité et ferait échouer le +-- déploiement. Ces doublons éventuels sont à traiter à la main. +UPDATE "User" AS u +SET "email" = lower(trim(u."email")) +WHERE u."email" <> lower(trim(u."email")) + AND NOT EXISTS ( + SELECT 1 + FROM "User" AS other + WHERE other."id" <> u."id" + AND lower(trim(other."email")) = lower(trim(u."email")) + ); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89737cd..822956c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,6 +51,11 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + // Compte employé créé par un admin avec un mot de passe provisoire envoyé + // par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe + // est autorisé (voir auth.middleware.js). + mustChangePassword Boolean @default(false) + provider AuthProvider @default(LOCAL) providerId String? avatarUrl String? diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index 370608d..eaffa64 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -1,4 +1,5 @@ const prisma = require("../src/config/prisma"); +const { DEFAULT_START_DATE } = require("../src/utils/gameWindow"); async function main() { await prisma.lot.deleteMany(); @@ -42,8 +43,8 @@ async function main() { const existingSettings = await prisma.gameSettings.findFirst(); if (!existingSettings) { - await prisma.gameSettings.create({ data: {} }); - console.log("Date de début du jeu-concours initialisée à aujourd'hui."); + await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } }); + console.log("Date de début du jeu-concours initialisée au 2 octobre 2026."); } else { console.log("Date de début du jeu-concours déjà définie, inchangée."); } diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index 1e9a434..e69fe73 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,17 +1,22 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const FacebookStrategy = require("passport-facebook").Strategy; const crypto = require("crypto"); const prisma = require("./prisma"); const transporter = require("./mailer"); +const getFrontendUrl = require("../utils/frontendUrl"); +const { normalizeEmail } = require("../validations/email.validation"); + +const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" }; // 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 +// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du +// provider 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) { +async function sendPasswordSetupEmail(user, providerLabel) { const token = crypto.randomBytes(32).toString("hex"); await prisma.user.update({ @@ -22,8 +27,7 @@ async function sendPasswordSetupEmail(user) { }, }); - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`; + const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -32,9 +36,9 @@ async function sendPasswordSetupEmail(user) { html: `

Bienvenue ${user.firstname} !

-

Votre compte a été créé avec Google. Vous pouvez continuer à vous +

Votre compte a été créé avec ${providerLabel}. 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.

+ également à votre compte sans passer par ${providerLabel}.

Définir mon mot de passe @@ -42,95 +46,126 @@ async function sendPasswordSetupEmail(user) {

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.

+ connexion ${providerLabel} continuera de fonctionner.

`, }); } +// Callback de vérification commun à tous les providers : passport normalise +// le profil (id, name, emails, photos), seule la valeur de `provider` change. +function verifyOAuthProfile(provider) { + const providerLabel = PROVIDER_LABELS[provider]; + + return async (accessToken, refreshToken, profile, done) => { + try { + const email = normalizeEmail(profile.emails?.[0]?.value); + + // Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone. + if (!email) { + return done(null, false, { + message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`, + }); + } + + // 1. Compte déjà lié à ce compte externe : connexion directe. + const byProvider = await prisma.user.findUnique({ + where: { + provider_providerId: { provider, providerId: profile.id }, + }, + }); + + if (byProvider) return done(null, byProvider); + + // 2. Un compte existe avec le même email : on le rattache au provider. + // L'email étant déjà validé par le provider, 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, + 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, + providerId: profile.id, + avatarUrl: profile.photos?.[0]?.value || null, + isEmailVerified: true, + }, + }); + + // 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, providerLabel); + } catch (mailError) { + console.error("Erreur envoi email de definition du mot de passe:", mailError); + } + + return done(null, created); + } catch (error) { + return done(error); + } + }; +} + // Authentification stateless : le callback OAuth émet directement notre JWT, // donc pas de session Express ni de serialize/deserialize à brancher. +const apiUrl = process.env.APP_URL || "http://localhost:5000"; + const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET); +const facebookEnabled = + Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_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`, + callbackURL: `${apiUrl}/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, - }, - }); - - // 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); - } - } + verifyOAuthProfile("GOOGLE") ) ); } -module.exports = { passport, googleEnabled }; +if (facebookEnabled) { + passport.use( + new FacebookStrategy( + { + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + callbackURL: `${apiUrl}/api/auth/facebook/callback`, + // Sans profileFields, la Graph API ne renvoie que l'id et le nom. + profileFields: ["id", "displayName", "name", "emails", "photos"], + // appsecret_proof : empêche l'usage d'un access token volé ailleurs. + enableProof: true, + }, + verifyOAuthProfile("FACEBOOK") + ) + ); +} + +module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile }; diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index ecca247..93410e1 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -2,7 +2,8 @@ const bcrypt = require("bcrypt"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); -const { getGameWindow } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); +const getFrontendUrl = require("../utils/frontendUrl"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => { } }; +// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier. +const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; +// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets). +const TEMP_PASSWORD_SPECIALS = "!@#$%*?"; +const TEMP_PASSWORD_LENGTH = 12; + +// Respecte la règle commune des mots de passe (au moins un caractère spécial), +// placé à une position aléatoire. +function generateTempPassword() { + const chars = []; + for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) { + chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]); + } + const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)]; + chars.splice(crypto.randomInt(chars.length + 1), 0, special); + return chars.join(""); +} + +// L'admin ne choisit pas le mot de passe de l'employé : on en génère un +// provisoire, envoyé uniquement par email, que l'employé devra remplacer à +// sa première connexion (mustChangePassword). const createEmployee = async (req, res) => { try { - const { firstname, lastname, email, password, birthdate, gender } = req.body; + const { firstname, lastname, email, birthdate, gender } = req.body; const existingUser = await prisma.user.findUnique({ where: { email }, @@ -180,7 +202,8 @@ const createEmployee = async (req, res) => { }); } - const hashedPassword = await bcrypt.hash(password, 10); + const tempPassword = generateTempPassword(); + const hashedPassword = await bcrypt.hash(tempPassword, 10); const employee = await prisma.user.create({ data: { @@ -192,6 +215,8 @@ const createEmployee = async (req, res) => { gender, role: "EMPLOYEE", marketingOptIn: false, + isEmailVerified: true, + mustChangePassword: true, }, select: { id: true, @@ -203,8 +228,45 @@ const createEmployee = async (req, res) => { }, }); + const loginUrl = `${getFrontendUrl()}/connexion`; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre compte employé Thé Tip Top", + html: ` +
+

Bienvenue ${firstname} !

+

Un compte employé Thé Tip Top vient d'être créé pour vous.

+

Voici vos identifiants de première connexion :

+

+ Email : ${email}
+ Mot de passe provisoire : ${tempPassword} +

+

+ + Me connecter + +

+

Dès votre première connexion, vous devrez + choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera + plus ensuite.

+
+ `, + }); + } catch (mailError) { + // Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe : + // on annule la création pour que l'admin puisse simplement réessayer. + console.error("Erreur envoi email employé:", mailError); + await prisma.user.delete({ where: { id: employee.id } }); + return res.status(502).json({ + message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.", + }); + } + return res.status(201).json({ - message: "Employé créé avec succès.", + message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.", employee, }); } catch (error) { @@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => { } }; +const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true }; + +// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur +// le prénom, le nom ou l'email. +const getEmployees = async (req, res) => { + try { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const contains = { contains: q, mode: "insensitive" }; + + const employees = await prisma.user.findMany({ + where: { + role: "EMPLOYEE", + ...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }), + }, + select: { + ...PERSON_SELECT, + createdAt: true, + mustChangePassword: true, + _count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } }, + }, + orderBy: [{ lastname: "asc" }, { firstname: "asc" }], + }); + + return res.json({ + employees: employees.map(({ _count, ...employee }) => ({ + ...employee, + deliveredCount: _count.claimsValidated, + })), + }); + } catch (error) { + console.error("Erreur liste employés:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." }); + } +}; + +// Lots remis par un employé, du plus récent au plus ancien. +const getEmployeeDeliveries = async (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ message: "Identifiant d'employé invalide." }); + } + + const employee = await prisma.user.findFirst({ + where: { id, role: "EMPLOYEE" }, + select: PERSON_SELECT, + }); + + if (!employee) { + return res.status(404).json({ message: "Employé introuvable." }); + } + + const claims = await prisma.claim.findMany({ + where: { validatedByEmployeeId: id, status: "DELIVERED" }, + orderBy: { claimedAt: "desc" }, + include: { + participation: { + include: { + ticket: { select: { code: true } }, + lot: { select: { name: true, value: true } }, + user: { select: PERSON_SELECT }, + }, + }, + }, + }); + + return res.json({ + employee, + deliveries: claims.map((claim) => ({ + claimId: claim.id, + ticketCode: claim.participation.ticket.code, + lot: claim.participation.lot, + client: claim.participation.user, + deliveredAt: claim.claimedAt, + })), + }); + } catch (error) { + console.error("Erreur remises employé:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." }); + } +}; + +// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis +// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi. +const getDeliveryByTicketCode = async (req, res) => { + try { + const code = req.params.code.trim().toUpperCase(); + const gameWindow = await getGameWindow(); + + const ticket = await prisma.ticket.findUnique({ + where: { code }, + include: { + lot: { select: { name: true, value: true } }, + participation: { + include: { + user: { select: PERSON_SELECT }, + claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } }, + }, + }, + }, + }); + + if (!ticket) { + return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." }); + } + + const claim = ticket.participation?.claim; + + return res.json({ + ticketCode: ticket.code, + lot: ticket.lot, + status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED", + playedAt: ticket.usedAt, + client: ticket.participation?.user ?? null, + deliveredBy: claim?.validatedByEmployee ?? null, + deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null, + }); + } catch (error) { + console.error("Erreur recherche remise:", error); + return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." }); + } +}; + module.exports = { getStats, getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cef2552..171108a 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); +const getFrontendUrl = require("../utils/frontendUrl"); +const { passwordRuleError } = require("../validations/password.validation"); +const { normalizeEmail } = require("../validations/email.validation"); const register = async (req, res) => { try { @@ -210,6 +213,7 @@ const login = async (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }, }); } catch (error) { @@ -220,9 +224,47 @@ const login = async (req, res) => { } }; +const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min + +// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie +// le lien par email. Partagé par « mot de passe oublié » et par le profil. +async function sendPasswordResetEmail(user) { + const resetPasswordToken = crypto.randomBytes(32).toString("hex"); + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken, + resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS), + }, + }); + + // 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 resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`; + + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: user.email, + subject: "Réinitialisation de votre mot de passe Thé Tip Top", + html: ` +

Réinitialisation du mot de passe

+

Bonjour ${user.firstname},

+

Vous avez demandé à définir un nouveau mot de passe.

+

Cliquez sur le lien ci-dessous pour le choisir :

+ Choisir mon nouveau mot de passe +

Ce lien expire dans 30 minutes.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+ `, + }); +} + +const FORGOT_PASSWORD_MESSAGE = + "Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé."; + const forgotPassword = async (req, res) => { try { - const { email } = req.body; + const email = normalizeEmail(req.body.email); if (!email) { return res.status(400).json({ @@ -236,45 +278,12 @@ const forgotPassword = async (req, res) => { // Sécurité : ne pas dire si l'email existe ou non if (!user) { - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } - const resetPasswordToken = crypto.randomBytes(32).toString("hex"); - const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min + await sendPasswordResetEmail(user); - await prisma.user.update({ - where: { id: user.id }, - data: { - resetPasswordToken, - resetPasswordExpires, - }, - }); - - // 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, - to: user.email, - subject: "Réinitialisation de votre mot de passe Thé Tip Top", - html: ` -

Réinitialisation du mot de passe

-

Bonjour ${user.firstname},

-

Vous avez demandé la réinitialisation de votre mot de passe.

-

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :

- Réinitialiser mon mot de passe -

Ce lien expire dans 30 minutes.

-

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

- `, - }); - - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error("Erreur forgotPassword:", error); return res.status(500).json({ @@ -283,15 +292,27 @@ const forgotPassword = async (req, res) => { } }; +// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à +// l'adresse du compte. Authentifié, donc sans captcha. +const sendPasswordSetupLink = async (req, res) => { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + await sendPasswordResetEmail(user); + return res.json({ message: `Lien envoyé à ${user.email}.` }); + } catch (error) { + console.error("Erreur sendPasswordSetupLink:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." }); + } +}; + const resetPassword = async (req, res) => { try { const { token } = req.params; const { password, confirmPassword } = req.body; - if (!password || password.length < 8) { - return res.status(400).json({ - message: "Le nouveau mot de passe doit contenir au moins 8 caractères.", - }); + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } // La confirmation est vérifiée côté serveur aussi : le contrôle du @@ -325,6 +346,14 @@ const resetPassword = async (req, res) => { password: hashedPassword, resetPasswordToken: null, resetPasswordExpires: null, + // Le lien reçu par email prouve l'accès à la boîte : le mot de passe + // choisi ici remplace aussi un éventuel mot de passe provisoire, et + // l'adresse est considérée comme validée (sinon la connexion qui suit + // serait refusée pour email non vérifié). + mustChangePassword: false, + isEmailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, }, }); @@ -341,7 +370,8 @@ const resetPassword = async (req, res) => { const verifyCode = async (req, res) => { try { - const { email, code } = req.body; + const { code } = req.body; + const email = normalizeEmail(req.body.email); if (!email || !code) { return res.status(400).json({ message: "Email et code requis." }); @@ -397,7 +427,7 @@ const verifyCode = async (req, res) => { // 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"; + const frontendUrl = getFrontendUrl(); try { const user = req.user; @@ -423,10 +453,15 @@ const oauthCallback = (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }) ).toString("base64"); - return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); + // encodeURIComponent : le base64 peut contenir des « + » que le front + // relirait comme des espaces dans la query string. + return res.redirect( + `${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}` + ); } catch (error) { console.error("Erreur oauthCallback:", error); return res.redirect( @@ -486,6 +521,47 @@ const deleteAccount = async (req, res) => { } }; +// Première connexion d'un employé : remplace le mot de passe provisoire reçu +// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne +// le redemande donc pas. +const setInitialPassword = async (req, res) => { + try { + const { password, confirmPassword } = req.body; + + if (!req.user.mustChangePassword) { + return res.status(400).json({ message: "Votre mot de passe a déjà été défini." }); + } + + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." }); + } + + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + + if (user.password && (await bcrypt.compare(password, user.password))) { + return res.status(400).json({ + message: "Choisissez un mot de passe différent du mot de passe provisoire.", + }); + } + + const hashed = await bcrypt.hash(password, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { password: hashed, mustChangePassword: false }, + }); + + return res.json({ message: "Mot de passe enregistré." }); + } catch (error) { + console.error("Erreur setInitialPassword:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." }); + } +}; + const changePassword = async (req, res) => { try { const { currentPassword, newPassword } = req.body; @@ -495,8 +571,9 @@ const changePassword = async (req, res) => { return res.status(400).json({ message: "Les deux mots de passe sont requis." }); } - if (newPassword.length < 8) { - return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." }); + const passwordError = passwordRuleError(newPassword); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -531,7 +608,9 @@ module.exports = { verifyCode, resetPassword, forgotPassword, + sendPasswordSetupLink, changePassword, + setInitialPassword, deleteAccount, oauthCallback, }; \ No newline at end of file diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 9725af2..b7593d6 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -185,7 +185,20 @@ const myGains = async (req, res) => { } }; +// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles +// suivent la date de début fixée par l'admin. +const getPublicGameWindow = async (req, res) => { + try { + const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow(); + return res.json({ configured, phase, startDate, participationDeadline, claimDeadline }); + } catch (error) { + console.error("Erreur lecture dates du jeu:", error); + return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." }); + } +}; + module.exports = { participate, myGains, + getPublicGameWindow, }; \ No newline at end of file diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index 072501e..c661a5e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -1,6 +1,11 @@ const jwt = require("jsonwebtoken"); const prisma = require("../config/prisma"); +// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été +// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API +// directement pour contourner la page du front. +const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"]; + const authMiddleware = async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => { role: true, provider: true, password: true, + mustChangePassword: true, }, }); @@ -34,6 +40,14 @@ const authMiddleware = async (req, res, next) => { }); } + const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, ""); + if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) { + return res.status(403).json({ + message: "Vous devez choisir un nouveau mot de passe avant de continuer.", + code: "PASSWORD_CHANGE_REQUIRED", + }); + } + // 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. diff --git a/backend/src/middlewares/captcha.middleware.js b/backend/src/middlewares/captcha.middleware.js new file mode 100644 index 0000000..8fe7eb3 --- /dev/null +++ b/backend/src/middlewares/captcha.middleware.js @@ -0,0 +1,34 @@ +const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +// Vérifie le jeton Cloudflare Turnstile envoyé par le front dans +// `captchaToken`. Sans TURNSTILE_SECRET_KEY, la vérification est ignorée +// (même logique que googleEnabled dans config/passport.js). +// À placer AVANT validate() : zod retire les clés inconnues de req.body. +const verifyCaptcha = async (req, res, next) => { + const secret = process.env.TURNSTILE_SECRET_KEY; + if (!secret) return next(); + + const token = req.body?.captchaToken; + if (!token) { + return res.status(400).json({ message: "Veuillez confirmer que vous n'êtes pas un robot." }); + } + + try { + const response = await fetch(VERIFY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret, response: token, remoteip: req.ip }), + }); + const data = await response.json(); + + if (!data.success) { + return res.status(400).json({ message: "Vérification anti-robot échouée, veuillez réessayer." }); + } + return next(); + } catch (error) { + console.error("Erreur vérification Turnstile :", error); + return res.status(503).json({ message: "Service de vérification indisponible, réessayez plus tard." }); + } +}; + +module.exports = verifyCaptcha; diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index acd0e54..43427db 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -4,6 +4,9 @@ const { getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, @@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts); */ router.post("/employees", validate(createEmployeeSchema), createEmployee); +/** + * @swagger + * /api/admin/employees: + * get: + * summary: Liste des comptes employés avec leur nombre de lots remis + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: q + * schema: + * type: string + * description: Filtre sur le prénom, le nom ou l'email + * responses: + * 200: + * description: Employés + */ +router.get("/employees", getEmployees); + +/** + * @swagger + * /api/admin/employees/{id}/deliveries: + * get: + * summary: Lots remis par un employé + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Employé et liste de ses remises (ticket, lot, client, date) + * 404: + * description: Employé introuvable + */ +router.get("/employees/:id/deliveries", getEmployeeDeliveries); + +/** + * @swagger + * /api/admin/deliveries/{code}: + * get: + * summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: code + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle + * 404: + * description: Aucun ticket ne correspond à ce numéro + */ +router.get("/deliveries/:code", getDeliveryByTicketCode); + /** * @swagger * /api/admin/draw: diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index c697f00..dcb3ed8 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,9 +1,11 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); +const verifyCaptcha = require("../middlewares/captcha.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); -const { passport, googleEnabled } = require("../config/passport"); +const { passport, googleEnabled, facebookEnabled } = require("../config/passport"); +const getFrontendUrl = require("../utils/frontendUrl"); const router = express.Router(); @@ -33,7 +35,7 @@ const router = express.Router(); * 201: * description: Utilisateur créé */ -router.post("/register", validate(registerSchema), register); +router.post("/register", verifyCaptcha, validate(registerSchema), register); /** * @swagger @@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register); * description: Connexion réussie */ -router.post("/login", validate(loginSchema), login); +router.post("/login", verifyCaptcha, validate(loginSchema), login); /** * @swagger @@ -111,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail); * type: string * format: email * example: jean.dupont@gmail.com + * captchaToken: + * type: string + * description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée) * responses: * 200: * description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé. * 400: - * description: Email requis. + * description: Email requis, ou vérification anti-robot manquante. * 500: * description: Erreur serveur. */ -router.post("/forgot-password", forgotPassword); +// Page publique : le captcha empêche d'envoyer des emails en masse vers +// n'importe quelle adresse. +router.post("/forgot-password", verifyCaptcha, forgotPassword); + +/** + * @swagger + * /api/auth/password-setup-link: + * post: + * summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe + * tags: + * - Auth + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Lien envoyé à l'adresse du compte. + */ +router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink); /** * @swagger * /api/auth/reset-password/{token}: @@ -160,6 +182,37 @@ router.post("/reset-password/:token", resetPassword); router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); + +/** + * @swagger + * /api/auth/initial-password: + * post: + * summary: Remplace le mot de passe provisoire d'un employé à sa première connexion + * tags: + * - Auth + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - password + * - confirmPassword + * properties: + * password: + * type: string + * confirmPassword: + * type: string + * responses: + * 200: + * description: Mot de passe enregistré, l'accès complet est rétabli. + * 400: + * description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini. + */ +router.post("/initial-password", authMiddleware, setInitialPassword); router.delete("/account", authMiddleware, deleteAccount); /** @@ -187,30 +240,73 @@ router.delete("/account", authMiddleware, deleteAccount); * 302: * description: Redirection vers FRONTEND_URL/auth/callback avec le token. */ -if (googleEnabled) { - router.get( - "/google", - passport.authenticate("google", { scope: ["profile", "email"], session: false }) - ); + +/** + * @swagger + * /api/auth/facebook: + * get: + * summary: Démarre la connexion via Facebook (redirection OAuth 2.0) + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers l'écran de consentement Facebook. + * 503: + * description: Connexion Facebook non configurée sur ce serveur. + */ + +/** + * @swagger + * /api/auth/facebook/callback: + * get: + * summary: Retour de Facebook, émet le JWT et redirige vers le front + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers FRONTEND_URL/auth/callback avec le token. + */ + +function mountOAuthRoutes({ path, strategy, label, enabled, scope }) { + if (!enabled) { + // 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 ${label} n'est pas configurée sur ce serveur.` }); + + router.get(path, notConfigured); + router.get(`${path}/callback`, notConfigured); + return; + } + + router.get(path, passport.authenticate(strategy, { scope, session: false })); router.get( - "/google/callback", - passport.authenticate("google", { + `${path}/callback`, + passport.authenticate(strategy, { session: false, - failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( - "Connexion Google refusée." + failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent( + `Connexion ${label} 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); } +mountOAuthRoutes({ + path: "/google", + strategy: "google", + label: "Google", + enabled: googleEnabled, + scope: ["profile", "email"], +}); + +mountOAuthRoutes({ + path: "/facebook", + strategy: "facebook", + label: "Facebook", + enabled: facebookEnabled, + scope: ["email"], +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/employee.routes.js b/backend/src/routes/employee.routes.js index c5b203c..0665679 100644 --- a/backend/src/routes/employee.routes.js +++ b/backend/src/routes/employee.routes.js @@ -36,7 +36,7 @@ const router = express.Router(); router.get( "/ticket/:code", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), getGainByTicketCode ); @@ -69,7 +69,7 @@ router.get( router.patch( "/claim/:id/deliver", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), deliverClaim ); diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 69c4eaa..64a338e 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { participate, myGains } = require("../controllers/game.controller"); +const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); @@ -41,4 +41,17 @@ router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(p router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); +/** + * @swagger + * /api/game/window: + * get: + * summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Game + * responses: + * 200: + * description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré + */ +router.get("/window", getPublicGameWindow); + module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/frontendUrl.js b/backend/src/utils/frontendUrl.js new file mode 100644 index 0000000..3d0c3da --- /dev/null +++ b/backend/src/utils/frontendUrl.js @@ -0,0 +1,9 @@ +// FRONTEND_URL peut lister plusieurs origines séparées par des virgules (prod : +// domaine nu + www), ce qui sert à CORS dans app.js. Pour construire un lien +// (email, redirection OAuth), on ne garde que la première, l'URL canonique. +function getFrontendUrl() { + const first = (process.env.FRONTEND_URL || "").split(",")[0].trim(); + return (first || "http://localhost:5173").replace(/\/+$/, ""); +} + +module.exports = getFrontendUrl; diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index 65c19c1..47a9fbd 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -11,6 +11,11 @@ const PHASES = { CLOSED: "CLOSED", }; +// Date de lancement prévue par le règlement : du 2 au 31 octobre 2026 (heure +// de Paris). Utilisée pour initialiser la base ; l'admin peut la corriger +// depuis son tableau de bord. +const DEFAULT_START_DATE = new Date("2026-10-02T00:00:00+02:00"); + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; @@ -95,6 +100,7 @@ module.exports = { effectiveClaimStatus, participationClosedMessage, PHASES, + DEFAULT_START_DATE, GAME_DURATION_DAYS, CLAIM_EXTENSION_DAYS, }; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index c6771bf..3d47820 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -1,10 +1,11 @@ const { z } = require("zod"); +const { emailSchema } = require("./email.validation"); const createEmployeeSchema = z.object({ firstname: z.string().min(2), lastname: z.string().min(2), - email: z.string().email(), - password: z.string().min(8), + // Pas de mot de passe : il est généré côté serveur (voir createEmployee). + email: emailSchema, birthdate: z.string().optional(), gender: z.string().optional(), }); diff --git a/backend/src/validations/auth.validation.js b/backend/src/validations/auth.validation.js index 4061a73..e8477e5 100644 --- a/backend/src/validations/auth.validation.js +++ b/backend/src/validations/auth.validation.js @@ -1,17 +1,27 @@ const { z } = require("zod"); +const { passwordSchema } = require("./password.validation"); +const { emailSchema } = require("./email.validation"); -const registerSchema = z.object({ - firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), - lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), - email: z.string().email("Email invalide"), - password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), - birthdate: z.string().optional(), - gender: z.string().optional(), - marketingOptIn: z.boolean().optional(), -}); +const registerSchema = z + .object({ + firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), + lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), + email: emailSchema, + password: passwordSchema, + // Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas + // un appel direct à l'API. + confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }), + birthdate: z.string().optional(), + gender: z.string().optional(), + marketingOptIn: z.boolean().optional(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Les deux mots de passe ne correspondent pas", + path: ["confirmPassword"], + }); const loginSchema = z.object({ - email: z.string().email("Email invalide"), + email: emailSchema, password: z.string().min(1, "Mot de passe requis"), }); diff --git a/backend/src/validations/email.validation.js b/backend/src/validations/email.validation.js new file mode 100644 index 0000000..715789c --- /dev/null +++ b/backend/src/validations/email.validation.js @@ -0,0 +1,11 @@ +const { z } = require("zod"); + +// Les emails sont stockés et recherchés en minuscules, sans espaces autour : +// « Jean@Exemple.fr » et « jean@exemple.fr » désignent le même compte. +function normalizeEmail(email) { + return typeof email === "string" ? email.trim().toLowerCase() : email; +} + +const emailSchema = z.string().trim().toLowerCase().email("Email invalide"); + +module.exports = { emailSchema, normalizeEmail }; diff --git a/backend/src/validations/password.validation.js b/backend/src/validations/password.validation.js new file mode 100644 index 0000000..a04e1bf --- /dev/null +++ b/backend/src/validations/password.validation.js @@ -0,0 +1,21 @@ +const { z } = require("zod"); + +// Règle de mot de passe, reprise à l'identique par le front +// (frontend/src/utils/passwordRules.js) : plus de 7 caractères et au moins un +// caractère spécial, c'est-à-dire ni lettre (accentuée comprise), ni chiffre, +// ni espace. +const SPECIAL_CHARACTER = /[^\p{L}\p{N}\s]/u; + +const passwordSchema = z + .string() + .min(8, "Le mot de passe doit contenir au moins 8 caractères") + .regex(SPECIAL_CHARACTER, "Le mot de passe doit contenir au moins un caractère spécial"); + +// Pour les contrôleurs qui valident à la main : message de la première règle +// non respectée, ou null si le mot de passe est valide. +function passwordRuleError(password) { + const result = passwordSchema.safeParse(password ?? ""); + return result.success ? null : `${result.error.issues[0].message}.`; +} + +module.exports = { passwordSchema, passwordRuleError, SPECIAL_CHARACTER }; diff --git a/backend/tests/adminDeliveries.test.js b/backend/tests/adminDeliveries.test.js new file mode 100644 index 0000000..16198b8 --- /dev/null +++ b/backend/tests/adminDeliveries.test.js @@ -0,0 +1,144 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const PREFIX = "tracabilite-"; +const CODES = ["TRACE00001", "TRACE00002", "TRACE00003", "TRACE00004"]; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const asAdmin = (req) => req.set("Authorization", `Bearer ${ctx.tAdmin}`); + +async function cleanup() { + const users = await prisma.user.findMany({ where: { email: { startsWith: PREFIX } } }); + const ids = users.map((u) => u.id); + const participations = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: participations.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +// Ticket joué par le client, avec une réclamation éventuellement remise. +async function jouer(code, { remisPar, remisLe } = {}) { + const ticket = await prisma.ticket.create({ + data: { code, lotId: ctx.lot.id, isUsed: true, usedAt: new Date("2026-09-01T10:00:00Z"), userId: ctx.client.id }, + }); + const participation = await prisma.participation.create({ + data: { userId: ctx.client.id, ticketId: ticket.id, lotId: ctx.lot.id }, + }); + return prisma.claim.create({ + data: { + participationId: participation.id, + status: remisPar ? "DELIVERED" : "PENDING", + validatedByEmployeeId: remisPar?.id ?? null, + claimedAt: remisLe ?? null, + }, + }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + const mk = (email, role, firstname, lastname, extra = {}) => + prisma.user.create({ data: { firstname, lastname, email: PREFIX + email, password: h, role, isEmailVerified: true, ...extra } }); + + ctx.admin = await mk("admin@test.local", "ADMIN", "Alice", "Admin"); + ctx.marc = await mk("marc@test.local", "EMPLOYEE", "Marc", "Durand"); + ctx.sophie = await mk("sophie@test.local", "EMPLOYEE", "Sophie", "Bernard", { mustChangePassword: true }); + ctx.client = await mk("client@test.local", "CLIENT", "Chloé", "Martin"); + ctx.tAdmin = tk(ctx.admin); + ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + + await jouer(CODES[0], { remisPar: ctx.marc, remisLe: new Date("2026-09-10T14:30:00Z") }); + await jouer(CODES[1], { remisPar: ctx.marc, remisLe: new Date("2026-09-12T09:15:00Z") }); + await jouer(CODES[2]); + await prisma.ticket.create({ data: { code: CODES[3], lotId: ctx.lot.id } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Liste des employes", () => { + test("renvoie les employes avec leur nombre de lots remis", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees")).expect(200); + + const marc = res.body.employees.find((e) => e.id === ctx.marc.id); + const sophie = res.body.employees.find((e) => e.id === ctx.sophie.id); + expect(marc.deliveredCount).toBe(2); + expect(sophie.deliveredCount).toBe(0); + expect(sophie.mustChangePassword).toBe(true); + // Ni client, ni admin, ni hash de mot de passe. + expect(res.body.employees.every((e) => e.id !== ctx.client.id && e.id !== ctx.admin.id)).toBe(true); + expect(JSON.stringify(res.body)).not.toMatch(/"password"/); + }); + + test("filtre par nom ou email avec q", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees?q=BERNARD")).expect(200); + expect(res.body.employees.map((e) => e.id)).toEqual([ctx.sophie.id]); + }); + + test("est reserve a l admin", async () => { + await request(app).get("/api/admin/employees").set("Authorization", `Bearer ${tk(ctx.marc)}`).expect(403); + }); +}); + +describe("Lots remis par un employe", () => { + test("liste les remises de l employe, de la plus recente a la plus ancienne", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.marc.id}/deliveries`)).expect(200); + + expect(res.body.employee.email).toBe(PREFIX + "marc@test.local"); + expect(res.body.deliveries.map((d) => d.ticketCode)).toEqual([CODES[1], CODES[0]]); + expect(res.body.deliveries[0].client.firstname).toBe("Chloé"); + expect(res.body.deliveries[0].lot.name).toBe(ctx.lot.name); + expect(new Date(res.body.deliveries[0].deliveredAt).toISOString()).toBe("2026-09-12T09:15:00.000Z"); + }); + + test("renvoie une liste vide pour un employe sans remise", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.sophie.id}/deliveries`)).expect(200); + expect(res.body.deliveries).toEqual([]); + }); + + test("refuse un identifiant qui n est pas un employe", async () => { + await asAdmin(request(app).get(`/api/admin/employees/${ctx.client.id}/deliveries`)).expect(404); + await asAdmin(request(app).get("/api/admin/employees/abc/deliveries")).expect(400); + }); +}); + +describe("Recherche par numero de ticket", () => { + test("indique l employe qui a remis le lot et la date, sans tenir compte de la casse", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[0].toLowerCase()}`)).expect(200); + + expect(res.body.ticketCode).toBe(CODES[0]); + expect(res.body.status).toBe("DELIVERED"); + expect(res.body.deliveredBy).toMatchObject({ id: ctx.marc.id, firstname: "Marc", lastname: "Durand" }); + expect(new Date(res.body.deliveredAt).toISOString()).toBe("2026-09-10T14:30:00.000Z"); + expect(res.body.client.email).toBe(PREFIX + "client@test.local"); + }); + + test("un lot gagne mais pas encore remis n a pas d employe", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[2]}`)).expect(200); + + // PENDING, ou CANCELLED si la période de retrait du jeu en base est passée. + expect(["PENDING", "CANCELLED"]).toContain(res.body.status); + expect(res.body.deliveredBy).toBeNull(); + expect(res.body.deliveredAt).toBeNull(); + }); + + test("un ticket jamais joue est signale comme tel", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[3]}`)).expect(200); + + expect(res.body.status).toBe("NOT_PLAYED"); + expect(res.body.client).toBeNull(); + }); + + test("un numero inconnu renvoie 404", async () => { + await asAdmin(request(app).get("/api/admin/deliveries/INEXISTANT")).expect(404); + }); +}); diff --git a/backend/tests/captcha.middleware.test.js b/backend/tests/captcha.middleware.test.js new file mode 100644 index 0000000..e9539d9 --- /dev/null +++ b/backend/tests/captcha.middleware.test.js @@ -0,0 +1,75 @@ +const verifyCaptcha = require("../src/middlewares/captcha.middleware"); + +function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("verifyCaptcha", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + process.env.TURNSTILE_SECRET_KEY = "secret-test"; + global.fetch = jest.fn(); + }); + + afterEach(() => { + process.env.TURNSTILE_SECRET_KEY = ""; + global.fetch = originalFetch; + }); + + it("laisse passer sans vérifier quand aucune clé n'est configurée", async () => { + process.env.TURNSTILE_SECRET_KEY = ""; + const next = jest.fn(); + + await verifyCaptcha({ body: {} }, mockRes(), next); + + expect(next).toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("refuse une requête sans jeton", async () => { + const res = mockRes(); + const next = jest.fn(); + + await verifyCaptcha({ body: {} }, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it("appelle next quand Cloudflare valide le jeton", async () => { + global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: true }) }); + const next = jest.fn(); + + await verifyCaptcha({ body: { captchaToken: "jeton" }, ip: "1.2.3.4" }, mockRes(), next); + + expect(next).toHaveBeenCalled(); + const [, options] = global.fetch.mock.calls[0]; + expect(JSON.parse(options.body)).toEqual({ secret: "secret-test", response: "jeton", remoteip: "1.2.3.4" }); + }); + + it("refuse quand Cloudflare rejette le jeton", async () => { + global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: false }) }); + const res = mockRes(); + const next = jest.fn(); + + await verifyCaptcha({ body: { captchaToken: "faux" } }, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it("répond 503 quand Cloudflare est injoignable", async () => { + global.fetch.mockRejectedValue(new Error("réseau")); + jest.spyOn(console, "error").mockImplementation(() => {}); + const res = mockRes(); + + await verifyCaptcha({ body: { captchaToken: "jeton" } }, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(503); + console.error.mockRestore(); + }); +}); diff --git a/backend/tests/drawNotification.test.js b/backend/tests/drawNotification.test.js index 80c26f9..531f2aa 100644 --- a/backend/tests/drawNotification.test.js +++ b/backend/tests/drawNotification.test.js @@ -30,7 +30,30 @@ const jourJ = (d) => data: { startDate: new Date(Date.now() - d * DAY) }, }); + +// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique +// pour tout le jeu. On la met de cote le temps du test, puis on la restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { + id: sauvegarde.id, + userId: sauvegarde.userId, + prize: sauvegarde.prize, + drawnAt: sauvegarde.drawnAt, + }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.settingsId = row.id; @@ -51,6 +74,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); await cleanup(); await prisma.$disconnect(); @@ -67,13 +91,16 @@ describe("Notification du grand gagnant", () => { expect(res.body.emailSent).toBe(true); expect(mailer.sendMail).toHaveBeenCalledTimes(1); + // Le tirage est aleatoire parmi tous les participants : on verifie que + // l email part bien au gagnant annonce, quel qu il soit. + const gagnant = res.body.winner.user; const envoi = mailer.sendMail.mock.calls[0][0]; - expect(envoi.to).toBe(ctx.client.email); + expect(envoi.to).toBe(gagnant.email); expect(envoi.subject).toMatch(/gros lot/i); - expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain(gagnant.firstname); expect(envoi.html).toContain("360"); - await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + await prisma.grandPrizeWinner.deleteMany({}); }); test("un SMTP en panne n invalide pas le tirage", async () => { @@ -86,10 +113,11 @@ describe("Notification du grand gagnant", () => { 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 } }); + const enBase = await prisma.grandPrizeWinner.findFirst(); expect(enBase).not.toBeNull(); + expect(enBase.userId).toBe(res.body.winner.user.id); - await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + await prisma.grandPrizeWinner.deleteMany({}); }); test("aucun email n est envoye si le tirage est refuse", async () => { diff --git a/backend/tests/e2e.flow.test.js b/backend/tests/e2e.flow.test.js index ddb2ae2..fe92d46 100644 --- a/backend/tests/e2e.flow.test.js +++ b/backend/tests/e2e.flow.test.js @@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com"; const ticketCode = "ZZE2ETEST1"; let token; let createdTicketId; +let settingsBackup; beforeAll(async () => { await prisma.user.deleteMany({ where: { email: testEmail } }); await prisma.ticket.deleteMany({ where: { code: ticketCode } }); + + // Le parcours a besoin d'un jeu ouvert, quelle que soit la date de début + // enregistrée en base (par défaut le 2 octobre 2026) : on la place à hier + // le temps du test, puis on la restaure. + settingsBackup = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + const hier = new Date(Date.now() - 24 * 60 * 60 * 1000); + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: hier } }); + } else { + await prisma.gameSettings.create({ data: { startDate: hier } }); + } }); afterAll(async () => { + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: settingsBackup.startDate } }); + } else { + await prisma.gameSettings.deleteMany({}); + } + const user = await prisma.user.findUnique({ where: { email: testEmail } }); if (user) { await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } }); @@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { firstname: "E2E", lastname: "Test", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", }); expect(res.statusCode).toBe(201); @@ -62,7 +81,7 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { const res = await request(app).post("/api/auth/login").send({ email: testEmail, - password: "Password123", + password: "Password123!", }); expect(res.statusCode).toBe(200); diff --git a/backend/tests/employeeOnboarding.test.js b/backend/tests/employeeOnboarding.test.js new file mode 100644 index 0000000..1f052b7 --- /dev/null +++ b/backend/tests/employeeOnboarding.test.js @@ -0,0 +1,166 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passwordRuleError } = require("../src/validations/password.validation"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const PREFIX = "onboarding-"; +const ADMIN_EMAIL = PREFIX + "admin@test.local"; +const EMPLOYEE_EMAIL = PREFIX + "employe@test.local"; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + +// Récupère le mot de passe provisoire dans l'email envoyé à l'employé. +function tempPasswordFromMail() { + const html = mailer.sendMail.mock.calls[0][0].html; + return html.match(/Mot de passe provisoire : ]*>([^<]+)<\/strong>/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { startsWith: PREFIX } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ + data: { firstname: "Alice", lastname: "Admin", email: ADMIN_EMAIL, password: h, role: "ADMIN", isEmailVerified: true }, + }); + ctx.tAdmin = tk(ctx.admin); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Creation d un employe avec mot de passe provisoire", () => { + test("un echec d envoi de l email annule la creation du compte", async () => { + mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible")); + jest.spyOn(console, "error").mockImplementation(() => {}); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL }); + + console.error.mockRestore(); + expect(res.status).toBe(502); + expect(await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } })).toBeNull(); + }); + + test("l admin cree l employe sans mot de passe, un mot de passe provisoire est envoye", async () => { + mailer.sendMail.mockClear(); + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + // Un mot de passe choisi par l'admin est ignoré. + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL, password: "ChoixAdmin123" }); + + expect(res.status).toBe(201); + expect(res.body.employee.role).toBe("EMPLOYEE"); + // Le mot de passe ne transite jamais par la réponse faite à l'admin. + expect(JSON.stringify(res.body)).not.toMatch(/password/i); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMPLOYEE_EMAIL); + ctx.tempPassword = tempPasswordFromMail(); + expect(ctx.tempPassword).toHaveLength(12); + expect(ctx.tempPassword).not.toBe("ChoixAdmin123"); + // Le provisoire respecte lui aussi la règle commune. + expect(passwordRuleError(ctx.tempPassword)).toBeNull(); + + const enBase = await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } }); + expect(enBase.mustChangePassword).toBe(true); + expect(await bcrypt.compare("ChoixAdmin123", enBase.password)).toBe(false); + }); + + test("la connexion avec le mot de passe provisoire signale le changement obligatoire", async () => { + const res = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + + expect(res.status).toBe(200); + expect(res.body.user.mustChangePassword).toBe(true); + ctx.tEmployee = res.body.token; + }); + + test("tant que le mot de passe n est pas change, l API est bloquee sauf /me", async () => { + const bloque = await request(app).get("/api/employee/ticket/INCONNU").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(bloque.status).toBe(403); + expect(bloque.body.code).toBe("PASSWORD_CHANGE_REQUIRED"); + + const changement = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", `Bearer ${ctx.tEmployee}`) + .send({ currentPassword: ctx.tempPassword, newPassword: "Contournement1" }); + expect(changement.status).toBe(403); + + const me = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(me.status).toBe(200); + expect(me.body.user.mustChangePassword).toBe(true); + }); + + test("le nouveau mot de passe doit etre confirme et different du provisoire", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const different = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "AutreMdp456!" }); + expect(different.status).toBe(400); + expect(different.body.message).toMatch(/ne correspondent pas/); + + const court = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "court", confirmPassword: "court" }); + expect(court.status).toBe(400); + + const sansSpecial = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123", confirmPassword: "NouveauMdp123" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); + + const identique = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: ctx.tempPassword, confirmPassword: ctx.tempPassword }); + expect(identique.status).toBe(400); + expect(identique.body.message).toMatch(/provisoire/); + }); + + test("apres le changement, l acces est retabli et seul le nouveau mot de passe fonctionne", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const ok = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "NouveauMdp123!" }); + expect(ok.status).toBe(200); + + // Le ticket n'existe pas : un 404 prouve que la requête a franchi le filtre. + const acces = await request(app).get("/api/employee/ticket/INCONNU").set(auth); + expect(acces.status).not.toBe(403); + + const encore = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "EncoreUnAutre1!", confirmPassword: "EncoreUnAutre1!" }); + expect(encore.status).toBe(400); + + const ancien = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + expect(ancien.status).toBe(401); + + const nouveau = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: "NouveauMdp123!" }); + expect(nouveau.status).toBe(200); + expect(nouveau.body.user.mustChangePassword).toBe(false); + }); +}); diff --git a/backend/tests/facebookLogin.test.js b/backend/tests/facebookLogin.test.js new file mode 100644 index 0000000..ebc094f --- /dev/null +++ b/backend/tests/facebookLogin.test.js @@ -0,0 +1,113 @@ +// Identifiants factices posés avant le chargement de passport : la stratégie +// Facebook est ainsi enregistrée même sans vraie app Meta configurée. +process.env.FACEBOOK_APP_ID = "test-app-id"; +process.env.FACEBOOK_APP_SECRET = "test-app-secret"; + +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passport } = require("../src/config/passport"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const EMAIL = "facebook-login-test@test.local"; +const EMAIL_EXISTANT = "facebook-link-test@test.local"; + +function profil(overrides = {}) { + return { + id: "sim-facebook-id-987654", + displayName: "Léa Durand", + name: { givenName: "Léa", familyName: "Durand" }, + emails: [{ value: EMAIL }], + photos: [{ value: "https://example.test/fb.png" }], + ...overrides, + }; +} + +// Rejoue le callback de la strategie Facebook sans sortir sur le reseau. +function verifierProfilFacebook(profile) { + const strategy = passport._strategy("facebook"); + return new Promise((resolve, reject) => { + strategy._verify("access-token", "refresh-token", profile, (err, user, info) => { + if (err) reject(err); + else resolve({ user, info }); + }); + }); +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { in: [EMAIL, EMAIL_EXISTANT] } } }); +} + +beforeAll(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Connexion Facebook", () => { + beforeEach(() => jest.clearAllMocks()); + + test("la route de depart redirige vers Facebook", async () => { + const res = await request(app).get("/api/auth/facebook"); + + expect(res.status).toBe(302); + const url = new URL(res.headers.location); + expect(url.hostname).toBe("www.facebook.com"); + expect(url.searchParams.get("client_id")).toBe("test-app-id"); + expect(url.searchParams.get("scope")).toBe("email"); + expect(url.searchParams.get("redirect_uri")).toMatch(/\/api\/auth\/facebook\/callback$/); + }); + + test("un premier passage cree un compte FACEBOOK sans mot de passe", async () => { + mailer.sendMail.mockResolvedValue(true); + + const { user } = await verifierProfilFacebook(profil()); + + expect(user.provider).toBe("FACEBOOK"); + expect(user.providerId).toBe("sim-facebook-id-987654"); + expect(user.password).toBeNull(); + expect(user.isEmailVerified).toBe(true); + expect(user.firstname).toBe("Léa"); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].html).toContain("créé avec Facebook"); + }); + + test("un second passage reconnait le compte sans renvoyer d email", async () => { + const { user } = await verifierProfilFacebook(profil()); + + expect(user.email).toBe(EMAIL); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + + test("un compte existant avec le meme email est rattache a Facebook", async () => { + await prisma.user.create({ + data: { firstname: "Paul", lastname: "Martin", email: EMAIL_EXISTANT, password: "hash", isEmailVerified: false }, + }); + + const { user } = await verifierProfilFacebook( + profil({ id: "sim-facebook-id-link", emails: [{ value: EMAIL_EXISTANT }] }) + ); + + expect(user.provider).toBe("FACEBOOK"); + expect(user.isEmailVerified).toBe(true); + // Le mot de passe existant est conserve. + expect(user.password).toBe("hash"); + }); + + test("un compte Facebook sans email est refuse", async () => { + const { user, info } = await verifierProfilFacebook(profil({ id: "sim-sans-email", emails: undefined })); + + expect(user).toBe(false); + expect(info.message).toMatch(/Facebook/); + }); + + test("la connexion par mot de passe oriente vers le bouton Facebook", 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 Facebook/); + }); +}); diff --git a/backend/tests/forgotPassword.test.js b/backend/tests/forgotPassword.test.js new file mode 100644 index 0000000..0b95155 --- /dev/null +++ b/backend/tests/forgotPassword.test.js @@ -0,0 +1,167 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +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().mockResolvedValue(true) })); + +const EMAIL = "forgot-password-test@test.local"; +const NON_VERIFIE = "forgot-password-unverified@test.local"; +const INSCRIT = "forgot-password-register@test.local"; + +// Jeton extrait du lien reçu par email. +function tokenFromMail() { + const html = mailer.sendMail.mock.calls.at(-1)[0].html; + return html.match(/\/definir-mot-de-passe\/([a-f0-9]+)"/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { in: [EMAIL, NON_VERIFIE, INSCRIT] } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("AncienMdp1!", 10); + await prisma.user.create({ data: { firstname: "Léa", lastname: "Durand", email: EMAIL, password: h, isEmailVerified: true } }); + await prisma.user.create({ data: { firstname: "Tom", lastname: "Petit", email: NON_VERIFIE, password: h, isEmailVerified: false } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Mot de passe oublie", () => { + beforeEach(() => mailer.sendMail.mockClear()); + + test("un email inconnu recoit la meme reponse, sans envoi", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: "inconnu@test.local" }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/Si un compte existe/); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + + test("un email connu recoit un lien vers la page du front", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + const mail = mailer.sendMail.mock.calls[0][0]; + expect(mail.to).toBe(EMAIL); + const frontendUrl = (process.env.FRONTEND_URL || "http://localhost:5173").split(",")[0].trim(); + expect(mail.html).toContain(`${frontendUrl}/definir-mot-de-passe/`); + expect(tokenFromMail()).toHaveLength(64); + }); + + test("une nouvelle demande invalide le lien precedent", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const ancien = tokenFromMail(); + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const nouveau = tokenFromMail(); + + const res = await request(app) + .post(`/api/auth/reset-password/${ancien}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(res.status).toBe(400); + expect(nouveau).not.toBe(ancien); + }); + + test("le lien permet de changer le mot de passe une seule fois", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + + const ok = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(ok.status).toBe(200); + + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "AncienMdp1!" })).status).toBe(401); + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" })).status).toBe(200); + + const reutilise = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(reutilise.status).toBe(400); + expect(reutilise.body.message).toMatch(/invalide ou expiré/); + }); + + test("un lien expire est refuse", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + await prisma.user.update({ where: { email: EMAIL }, data: { resetPasswordExpires: new Date(Date.now() - 1000) } }); + + const res = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/invalide ou expiré/); + }); + + test("l email est reconnu quelle que soit la casse et malgre des espaces", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: ` ${EMAIL.toUpperCase()} ` }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + }); + + test("la reinitialisation valide un compte jamais verifie, qui peut ensuite se connecter", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: NON_VERIFIE }); + await request(app) + .post(`/api/auth/reset-password/${tokenFromMail()}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }) + .expect(200); + + const login = await request(app).post("/api/auth/login").send({ email: NON_VERIFIE, password: "NouveauMdp1!" }); + expect(login.status).toBe(200); + const enBase = await prisma.user.findUnique({ where: { email: NON_VERIFIE } }); + expect(enBase.isEmailVerified).toBe(true); + expect(enBase.emailVerifyToken).toBeNull(); + }); + + test("le profil connecte recoit le lien sur l adresse du compte, sans captcha", async () => { + await request(app).post("/api/auth/password-setup-link").expect(401); + + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" }).expect(200); + const res = await request(app) + .post("/api/auth/password-setup-link") + .set("Authorization", `Bearer ${login.body.token}`); + + expect(res.status).toBe(200); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + expect(tokenFromMail()).toHaveLength(64); + }); +}); + +describe("Emails normalises a l inscription et a la connexion", () => { + test("un email saisi avec majuscules est stocke en minuscules et reconnu a la connexion", async () => { + const res = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: ` ${INSCRIT.replace("forgot", "FORGOT")} `, + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + birthdate: "1990-05-14", + }); + expect(res.status).toBe(201); + + const enBase = await prisma.user.findUnique({ where: { email: INSCRIT } }); + expect(enBase).not.toBeNull(); + + // Même adresse avec une autre casse : doublon refusé. + const doublon = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: INSCRIT.toUpperCase(), + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + }); + expect(doublon.status).toBe(409); + + await prisma.user.update({ where: { email: INSCRIT }, data: { isEmailVerified: true } }); + const login = await request(app).post("/api/auth/login").send({ email: INSCRIT.toUpperCase(), password: "MotDePasse1!" }); + expect(login.status).toBe(200); + }); +}); diff --git a/backend/tests/gameFlow.test.js b/backend/tests/gameFlow.test.js index 040b603..76fc5ae 100644 --- a/backend/tests/gameFlow.test.js +++ b/backend/tests/gameFlow.test.js @@ -26,7 +26,30 @@ async function cleanup() { const setStart = (d) => prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } }); + +// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique +// pour tout le jeu. On la met de cote le temps du test, puis on la restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { + id: sauvegarde.id, + userId: sauvegarde.userId, + prize: sauvegarde.prize, + drawnAt: sauvegarde.drawnAt, + }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.orig = ctx.settings.startDate; @@ -46,6 +69,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } }); await cleanup(); await prisma.$disconnect(); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js index 8f87ea0..432f081 100644 --- a/backend/tests/gamePhases.test.js +++ b/backend/tests/gamePhases.test.js @@ -33,7 +33,25 @@ async function jourJ(offsetJours) { const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" }); + +// La table GrandPrizeWinner est unique pour tout le jeu : on met de cote un +// eventuel tirage reel le temps du test, puis on le restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { id: sauvegarde.id, userId: sauvegarde.userId, prize: sauvegarde.prize, drawnAt: sauvegarde.drawnAt }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.startDateOriginale = row ? row.startDate : null; @@ -49,6 +67,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); 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 } }); @@ -68,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen); expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED"); + // Route publique de la page d'accueil : sans configuration, pas de date, + // le front affiche alors les dates par défaut. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body).toMatchObject({ configured: false, startDate: null }); + 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); @@ -89,6 +113,12 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase); expect(r.body.gameWindow.phase).toBe("SCHEDULED"); + // La page d'accueil reçoit aussitôt la nouvelle date, sans authentification. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body.configured).toBe(true); + expect(publique.body.startDate).toBe(futur); + expect(new Date(publique.body.participationDeadline).getTime()).toBe(new Date(futur).getTime() + 30 * DAY); + 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)); diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js index b0ceb71..5992c5d 100644 --- a/backend/tests/googlePassword.test.js +++ b/backend/tests/googlePassword.test.js @@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => { 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" }); + .send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" }); expect(res.status).toBe(400); expect(res.body.message).toMatch(/ne correspondent pas/); @@ -105,14 +105,23 @@ decrire("Inscription Google et definition du mot de passe", () => { expect(res.body.message).toMatch(/8 caractères/); }); + test("un mot de passe sans caractere special est refuse", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/caractère spécial/); + }); + 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" }) + .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); + 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. @@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => { const change = await request(app) .patch("/api/auth/change-password") .set("Authorization", "Bearer " + login.body.token) - .send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" }); + .send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" }); expect(change.status).toBe(200); + + // Même règle qu'à l'inscription pour le changement depuis le profil. + const sansSpecial = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", "Bearer " + login.body.token) + .send({ currentPassword: "EncoreUnAutre2!", newPassword: "SansCaractere3" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); }); test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => { diff --git a/backend/tests/oauthCallback.test.js b/backend/tests/oauthCallback.test.js new file mode 100644 index 0000000..334a63b --- /dev/null +++ b/backend/tests/oauthCallback.test.js @@ -0,0 +1,64 @@ +// Ce fichier ne charge pas app.js, donc pas le .env : le JWT a besoin d'un secret. +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +const getFrontendUrl = require("../src/utils/frontendUrl"); +const { oauthCallback } = require("../src/controllers/auth.controller"); + +describe("getFrontendUrl", () => { + const original = process.env.FRONTEND_URL; + afterEach(() => { + process.env.FRONTEND_URL = original; + }); + + test("ne garde que la premiere origine d une liste (CORS prod)", () => { + process.env.FRONTEND_URL = "https://site.fr, https://www.site.fr"; + expect(getFrontendUrl()).toBe("https://site.fr"); + }); + + test("retire le slash final", () => { + process.env.FRONTEND_URL = "https://site.fr/"; + expect(getFrontendUrl()).toBe("https://site.fr"); + }); + + test("retombe sur localhost sans configuration", () => { + delete process.env.FRONTEND_URL; + expect(getFrontendUrl()).toBe("http://localhost:5173"); + }); +}); + +describe("oauthCallback", () => { + const original = process.env.FRONTEND_URL; + afterEach(() => { + process.env.FRONTEND_URL = original; + }); + + test("redirige vers une URL valide avec un profil lisible par le front", () => { + process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr"; + const res = { redirect: jest.fn() }; + // Accents (UTF-8) et « ~ » : ce profil produit un base64 contenant « + », + // qui deviendrait un espace sans encodage. + const user = { id: 7, firstname: "Chloé", lastname: "Lefèvre", email: "ab~test@test.local", role: "CLIENT" }; + expect(Buffer.from(JSON.stringify(user)).toString("base64")).toContain("+"); + + oauthCallback({ user }, res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(`${url.origin}${url.pathname}`).toBe("https://site.fr/auth/callback"); + expect(url.searchParams.get("token")).toBeTruthy(); + + // Même décodage que frontend/src/pages/AuthCallback.jsx. + const bytes = Uint8Array.from(atob(url.searchParams.get("user")), (c) => c.charCodeAt(0)); + expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual(user); + }); + + test("renvoie une erreur au front quand passport n a pas d utilisateur", () => { + process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr"; + const res = { redirect: jest.fn() }; + + oauthCallback({}, res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(url.origin).toBe("https://site.fr"); + expect(url.searchParams.get("error")).toMatch(/refusée/); + }); +}); diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js index 463821c..6d2cb6c 100644 --- a/backend/tests/register.mail.test.js +++ b/backend/tests/register.mail.test.js @@ -15,7 +15,8 @@ const payload = { firstname: "Camille", lastname: "Martin", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", birthdate: "1990-05-14", gender: "Femme", }; diff --git a/backend/tests/registerPassword.test.js b/backend/tests/registerPassword.test.js new file mode 100644 index 0000000..f288491 --- /dev/null +++ b/backend/tests/registerPassword.test.js @@ -0,0 +1,62 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const EMAIL = "register-password-test@test.local"; + +const payload = (password, confirmPassword = password) => ({ + firstname: "Camille", + lastname: "Martin", + email: EMAIL, + password, + confirmPassword, + birthdate: "1990-05-14", +}); + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: EMAIL } }); +} + +beforeEach(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Regles du mot de passe a l inscription", () => { + test.each([ + ["trop court", "Abc12!", /8 caractères/], + ["sans caractere special", "MotDePasse123", /caractère spécial/], + ["accents et espaces ne comptent pas comme speciaux", "Mot de passé 123", /caractère spécial/], + ])("refuse un mot de passe %s", async (_, password, message) => { + const res = await request(app).post("/api/auth/register").send(payload(password)); + + expect(res.status).toBe(400); + expect(res.body.errors.password[0]).toMatch(message); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).toBeNull(); + }); + + test("refuse une confirmation differente", async () => { + const res = await request(app).post("/api/auth/register").send(payload("MotDePasse1!", "MotDePasse2!")); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword[0]).toMatch(/ne correspondent pas/); + }); + + test("refuse une inscription sans confirmation", async () => { + const { confirmPassword, ...sansConfirmation } = payload("MotDePasse1!"); + const res = await request(app).post("/api/auth/register").send(sansConfirmation); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword).toBeDefined(); + }); + + test("accepte 8 caracteres avec un caractere special", async () => { + const res = await request(app).post("/api/auth/register").send(payload("abcdefg#")); + + expect(res.status).toBe(201); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).not.toBeNull(); + }); +}); diff --git a/backend/tests/setupEnv.js b/backend/tests/setupEnv.js new file mode 100644 index 0000000..b55f9ca --- /dev/null +++ b/backend/tests/setupEnv.js @@ -0,0 +1,4 @@ +// Exécuté avant chaque fichier de test, donc avant le dotenv de src/app.js : +// dotenv n'écrase pas une variable déjà définie, le captcha reste désactivé +// pour les tests d'API (captcha.middleware.test.js le teste à part). +process.env.TURNSTILE_SECRET_KEY = ""; diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 87014ba..f9ec95f 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -40,6 +40,15 @@ services: API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr + # 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:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" + command: > sh -c " npx prisma migrate deploy && diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index 9f7c7d2..04ff8b8 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -40,6 +40,15 @@ services: API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr + # 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:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" + command: > sh -c " npx prisma migrate deploy && diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 2f66ca7..32a1a90 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -38,7 +38,16 @@ services: JWT_SECRET: prod_secret_change_me APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr - FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr + FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr + + # 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:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/frontend/.gitignore b/frontend/.gitignore index fa3424c..91617cf 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a3816af..dc5a162 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,9 +1,10 @@ -FROM node:22-alpine AS build +FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci +RUN npx playwright install --with-deps chromium COPY . . @@ -13,6 +14,9 @@ ARG VITE_GA_ID ENV VITE_API_URL=${VITE_API_URL} ENV VITE_GA_ID=${VITE_GA_ID} +ARG VITE_TURNSTILE_SITE_KEY +ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} + RUN npm run build diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3..444d863 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,6 +8,7 @@ export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{js,jsx}'], + ignores: ['scripts/**'], extends: [ js.configs.recommended, reactHooks.configs.flat.recommended, @@ -18,4 +19,13 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, }, + { + // Scripts Node exécutés à la compilation (ex. prerender.js), jamais + // dans le navigateur : globals Node, pas de règles React. + files: ['scripts/**/*.js'], + extends: [js.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/frontend/index.html b/frontend/index.html index ee46fd3..42051f7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,43 @@ + Jeu concours - Thé Tip Top + + + + + + + + + + + + + + + + + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3aa17e6..5d8110f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,6 +6,12 @@ server { index index.html; location / { - try_files $uri $uri/ /index.html; + # $uri/index.html (et non $uri/) : sert directement le fichier des + # pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans + # passer par la redirection 301 vers l'URL à slash final que nginx + # déclenche pour une résolution de type répertoire ($uri/). Les + # canonical/sitemap pointent vers les URLs sans slash : elles + # doivent répondre 200 directement, pas via un redirect. + try_files $uri $uri/index.html /index.html; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b90ef3..a0187e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "ttt-front", "version": "0.0.0", "dependencies": { + "@marsidev/react-turnstile": "^1.6.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0" @@ -27,6 +28,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" @@ -802,6 +804,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz", + "integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -3419,6 +3430,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 57932d8..5847540 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/prerender.js", + "prerender": "node scripts/prerender.js", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", @@ -13,6 +14,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@marsidev/react-turnstile": "^1.6.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0" @@ -32,6 +34,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" diff --git a/frontend/public/og-image.webp b/frontend/public/og-image.webp new file mode 100644 index 0000000..37a7063 Binary files /dev/null and b/frontend/public/og-image.webp differ diff --git a/frontend/public/reglement-jeu-concours-100-gagnant.pdf b/frontend/public/reglement-jeu-concours-100-gagnant.pdf new file mode 100644 index 0000000..76b90f3 Binary files /dev/null and b/frontend/public/reglement-jeu-concours-100-gagnant.pdf differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 86a7682..97232b8 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -3,10 +3,13 @@ User-agent: * # Pages publiques autorisées Allow: / Allow: /accueil +Allow: /notre-concept Allow: /regles-jeu Allow: /contact Allow: /mentions-legales Allow: /confidentialite +Allow: /accessibilite +Allow: /cgu Allow: /sitemap Allow: /inscription Allow: /connexion @@ -20,4 +23,4 @@ Disallow: /saisir-code-unique Disallow: /perdu # Sitemap -Sitemap: https://thetiptop.fr/sitemap +Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 0000000..6e83105 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,14 @@ + + + https://www.dsp5-archi-o24a-4-5-g4.fr/accueil + https://www.dsp5-archi-o24a-4-5-g4.fr/notre-concept + https://www.dsp5-archi-o24a-4-5-g4.fr/regles-jeu + https://www.dsp5-archi-o24a-4-5-g4.fr/contact + https://www.dsp5-archi-o24a-4-5-g4.fr/mentions-legales + https://www.dsp5-archi-o24a-4-5-g4.fr/cgu + https://www.dsp5-archi-o24a-4-5-g4.fr/confidentialite + https://www.dsp5-archi-o24a-4-5-g4.fr/accessibilite + https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap + https://www.dsp5-archi-o24a-4-5-g4.fr/inscription + https://www.dsp5-archi-o24a-4-5-g4.fr/connexion + diff --git a/frontend/scripts/prerender.js b/frontend/scripts/prerender.js new file mode 100644 index 0000000..3a8963b --- /dev/null +++ b/frontend/scripts/prerender.js @@ -0,0 +1,54 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { preview } from 'vite' +import { chromium } from 'playwright' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = join(__dirname, '..') +const distDir = join(root, 'dist') + +// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules +// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les +// pages privées (profil, mes gains, admin...) restent en rendu 100% client, +// servies par le index.html générique via le fallback SPA de nginx. +const ROUTES = [ + '/accueil', + '/notre-concept', + '/regles-jeu', + '/contact', + '/mentions-legales', + '/confidentialite', + '/accessibilite', + '/cgu', + '/sitemap', + '/inscription', + '/connexion', +] + +async function main() { + const server = await preview({ root, preview: { port: 4173, strictPort: false } }) + const base = server.resolvedUrls.local[0].replace(/\/$/, '') + + const browser = await chromium.launch() + const page = await browser.newPage() + + for (const route of ROUTES) { + await page.goto(`${base}${route}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('h1') + + const html = await page.content() + const outDir = join(distDir, route.replace(/^\//, '')) + mkdirSync(outDir, { recursive: true }) + writeFileSync(join(outDir, 'index.html'), `\n${html}`) + console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`) + } + + await browser.close() + await server.close() +} + +main().catch((error) => { + console.error('Erreur de pré-rendu :', error) + process.exit(1) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ff45923..a4a39a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,10 @@ import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import AuthCallback from './pages/AuthCallback' import DefinirMotDePasse from './pages/DefinirMotDePasse' +import MotDePasseOublie from './pages/MotDePasseOublie' +import NouveauMotDePasse from './pages/NouveauMotDePasse' import Accueil from './pages/Accueil' +import NotreConcept from './pages/NotreConcept' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' import Employe from './pages/Employe' @@ -32,10 +35,13 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> {/* Pages publiques */} } /> + } /> } /> } /> } /> diff --git a/frontend/src/assets/img/banner-accueil.webp b/frontend/src/assets/img/banner-accueil.webp new file mode 100644 index 0000000..44665fb Binary files /dev/null and b/frontend/src/assets/img/banner-accueil.webp differ diff --git a/frontend/src/assets/img/banner-the-tip-top.webp b/frontend/src/assets/img/banner-the-tip-top.webp new file mode 100644 index 0000000..37a7063 Binary files /dev/null and b/frontend/src/assets/img/banner-the-tip-top.webp differ diff --git a/frontend/src/assets/img/img-contact.jpg b/frontend/src/assets/img/img-contact.jpg deleted file mode 100644 index 6c2ae69..0000000 Binary files a/frontend/src/assets/img/img-contact.jpg and /dev/null differ diff --git a/frontend/src/assets/img/img-contact.webp b/frontend/src/assets/img/img-contact.webp new file mode 100644 index 0000000..69f148e Binary files /dev/null and b/frontend/src/assets/img/img-contact.webp differ diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png deleted file mode 100644 index 6cf9105..0000000 Binary files a/frontend/src/assets/img/logo.png and /dev/null differ diff --git a/frontend/src/assets/img/logo.webp b/frontend/src/assets/img/logo.webp new file mode 100644 index 0000000..33a20de Binary files /dev/null and b/frontend/src/assets/img/logo.webp differ diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx new file mode 100644 index 0000000..c56e189 --- /dev/null +++ b/frontend/src/components/Captcha.jsx @@ -0,0 +1,19 @@ +import { Turnstile } from '@marsidev/react-turnstile' +import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha' + +// `ref` expose reset() : un jeton Turnstile n'est valable qu'une fois, il faut +// en redemander un après chaque envoi refusé. +export default function Captcha({ ref, onToken }) { + if (!CAPTCHA_ENABLED) return null + + return ( + onToken('')} + onError={() => onToken('')} + /> + ) +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index b2189dd..a86e272 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -20,7 +20,7 @@ export default function Footer() {