merge: synchronize develop with github main
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit

This commit is contained in:
2026-09-17 18:46:47 +00:00
111 changed files with 5622 additions and 845 deletions

View File

@@ -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=""

12
backend/.gitignore vendored
View File

@@ -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?

View File

@@ -1,368 +1,285 @@
# Thé Tip Top – Backend API
# Thé Tip Top – Backend API
## Présentation
Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice.
Backend REST du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique.
L’API fournit les services des espaces **CLIENT**, **EMPLOYEE** et **ADMIN** et prend notamment en charge :
Le backend expose une **API** **REST** sécurisée utilisée par :
- l'application client (participants)
- l'espace employé en boutique
- l'espace administrateur
- les futurs services externes de l'écosystème Thé Tip Top
- inscription, vérification email et connexion ;
- authentification JWT et Google OAuth 2.0 ;
- rôles et autorisations ;
- tickets, participations et gains ;
- remise des lots ;
- configuration des phases du jeu ;
- tirage au sort final ;
- notifications email ;
- administration et statistiques.
---
## Fonctionnalités
## Stack technique
### Authentification
| Technologie | Utilisation |
|---|---|
| Node.js 22 | Runtime |
| Express.js | API REST |
| PostgreSQL 16 | Base de données |
| Prisma ORM 6 | ORM et migrations |
| JWT | Authentification |
| Passport.js | Authentification externe |
| Google OAuth 2.0 | Connexion Google |
| Bcrypt | Hachage des mots de passe |
| Zod | Validation |
| Nodemailer | Emails |
| Helmet | Sécurité HTTP |
| express-rate-limit | Rate limiting |
| Swagger / OpenAPI | Documentation |
| Jest / Supertest | Tests |
| Docker | Conteneurisation |
- Inscription utilisateur
- Connexion sécurisée
- Authentification **JWT**
- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**)
- Hachage des mots de passe avec Bcrypt
- Validation des données avec **Zod**
## Authentification
### Jeu-concours
### Email / mot de passe
- Génération automatique des lots
- Génération des tickets gagnants
- Participation via code ticket unique
- Attribution automatique des gains
- Historique des participations
- Historique des gains remportés
Le parcours comprend l’inscription, le hachage Bcrypt, l’envoi d’un code de vérification par email, la validation du compte, la connexion et la génération d’un JWT.
### Espace Employé
### Google OAuth 2.0
- Recherche d'un gain via un code ticket
- Vérification du statut du gain
- Validation de la remise d'un lot
- Mise à jour automatique du statut du gain
### Espace Administrateur
- Tableau de bord statistique
- Liste des participants
- Liste des contacts ayant accepté les communications marketing
- Création de comptes employés
- Tirage au sort du grand gagnant
- Consultation du gagnant du gros lot
### Documentation API
- Documentation interactive avec **Swagger UI**
- Génération automatique de la documentation via **Swagger JSDoc**
- Tests des endpoints directement depuis le navigateur
---
## Architecture Technique
### Backend
- Node.js
- Express.js
### Base de données
- PostgreSQL
- **Prisma **ORM** 6**
- Prisma Client 6
- Prisma Migrate
- Prisma Studio
### Validation des données
- **Zod**
- Middleware de validation personnalisé
### Sécurité
- **JWT** (**JSON** Web Token)
- Bcrypt
- Gestion des rôles et permissions
- Validation des entrées utilisateur
### Documentation
- Swagger UI
- Swagger JSDoc
- OpenAPI 3.0
---
## Installation
### Cloner le projet
```bash
git clone <url-du-repository>
cd the-tip-top-backend
```text
Utilisateur
↓
Frontend
↓
API /api/auth/google
↓
Google OAuth 2.0
↓
Callback backend
↓
Création / récupération utilisateur
↓
JWT
↓
Redirection frontend
```
### Installer les dépendances
Les secrets OAuth restent hors du dépôt Git.
```bash
npm install
```
### Variables d'environnement
Créer un fichier `.env` à la racine du projet :
```env **PORT**=**5000**```
```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*```
```JWT_SECRET=*your_secret_key" ```
---
## Base de données
### Générer les migrations
```bash
npx prisma migrate dev
```
### Générer Prisma Client
```bash
npx prisma generate
```
### Exécuter le seed de la base
```bash
npx prisma db seed
```
### Générer les tickets du jeu-concours
```bash
npm run generate:tickets
```
### Ouvrir Prisma Studio
```bash
npx prisma studio
```
---
## Technologies utilisées
| Technologie | Version |
| ----------- | ---------------- |
| Node.js | 20+ |
| Express.js | 5 |
| PostgreSQL | 16+ |
| Prisma ORM | 6 |
| JWT | Dernière version |
| Bcrypt | Dernière version |
| Zod | Dernière version |
| Swagger UI | Dernière version |
---
## Lancement du projet
### Développement
Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code.
```bash
npm run dev
```
Serveur :
```txt [http://localhost:**5000**](http://localhost:**5000**) ```
---
## Documentation API
La documentation Swagger est accessible à l'adresse :
```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ```
Fonctionnalités disponibles :
- Consultation de toutes les routes
- Visualisation des schémas de données
- Test des endpoints
- Authentification via **JWT** avec le bouton **Authorize**
---
## Gestion des rôles
## Rôles
### CLIENT
Peut :
- Participer au jeu-concours
- Consulter ses gains
- Consulter son profil
- participer au jeu ;
- consulter ses gains ;
- gérer son profil.
### EMPLOYEE
Peut :
- Rechercher un gain
- Vérifier les informations du gagnant
- Valider la remise d'un lot
- rechercher et vérifier un gain ;
- valider la remise d’un lot.
### ADMIN
- consulter les statistiques ;
- créer des employés ;
- configurer le jeu ;
- effectuer le tirage final ;
- consulter le grand gagnant.
Peut :
## Jeu-concours
- Accéder aux statistiques
- Créer des employés
- Gérer les campagnes emailing
- Effectuer le tirage au sort final
- Consulter le gagnant du gros lot
Phases applicatives :
---
- `NOT_CONFIGURED`
- `SCHEDULED`
- `OPEN`
- `CLAIM_ONLY`
- `CLOSED`
## Routes API
Principales règles métier :
### Auth
- un ticket ne peut être utilisé qu’une fois ;
- les tickets sont gagnants ;
- les comptes EMPLOYEE et ADMIN ne participent pas comme clients ;
- plusieurs participations d’un client n’augmentent pas ses chances au tirage final ;
- un seul grand gagnant peut être enregistré ;
- le tirage final respecte les contraintes temporelles du jeu.
| Méthode | Route |
| ------- | ------------------ |
| POST | /api/auth/register |
| POST | /api/auth/login |
| GET | /api/auth/me |
## Notifications email
### Game
Nodemailer est utilisé notamment pour la vérification des comptes et les notifications liées aux gains et au tirage final. La configuration SMTP est fournie par variables d’environnement.
| Méthode | Route |
| ------- | --------------------- |
| POST | /api/game/participate |
| GET | /api/game/my-gains |
## Sécurité
### Employee
- Bcrypt ;
- JWT ;
- Google OAuth 2.0 ;
- Zod ;
- contrôle d’accès par rôle ;
- Helmet ;
- rate limiting ;
- secrets externalisés ;
- HTTPS via Traefik ;
- séparation DEV / PREPROD / PROD.
| Méthode | Route |
| ------- | ---------------------------- |
| GET | /api/employee/ticket/ |
| PATCH | /api/employee/claim//deliver |
### Admin
| Méthode | Route |
| ------- | -------------------- |
| GET | /api/admin/stats |
| GET | /api/admin/users |
| GET | /api/admin/emailing |
| POST | /api/admin/employees |
| POST | /api/admin/draw |
| GET | /api/admin/draw |
---
## Validation avec Zod
Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**.
Exemples :
- Inscription utilisateur
- Connexion
- Création d'employés
- Participation au jeu-concours
- Validation des paramètres de requête
Cette approche garantit :
- Des données cohérentes
- Une meilleure sécurité
- Des messages d'erreur explicites
- Une maintenance facilitée
---
> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP.
## Modèle de données principal
### User
- `User` : utilisateurs et rôles CLIENT / EMPLOYEE / ADMIN.
- `Ticket` : code unique, lot associé et état d’utilisation.
- `Lot` : lot, description, valeur et répartition.
- `Participation` : participation d’un utilisateur avec un ticket.
- `Claim` : gestion de la remise du gain.
- `GameSettings` : configuration temporelle du concours.
- `GrandPrizeWinner` : résultat du tirage final.
- **CLIENT**
- **EMPLOYEE**
- **ADMIN**
## Installation locale
### Ticket
Prérequis : Node.js 22, npm et PostgreSQL 16.
- Code unique
- Gain associé
- Utilisé / Non utilisé
### Lot
- Nom
- Description
- Valeur
- Pourcentage de répartition
### Participation
- Utilisateur
- Ticket
- Lot gagné
### Claim
- **PENDING**
- **DELIVERED**
- **CANCELLED**
### GrandPrizeWinner
- Gagnant du gros lot
- Date du tirage
- Récompense
---
## Prisma ORM 6
Le projet utilise **Prisma **ORM** 6** pour :
- La modélisation des données
- Les migrations de base de données
- Les requêtes **SQL** typées
- La génération automatique du client Prisma
- L'administration de la base via Prisma Studio
Commandes utiles :
```bash
npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio
```bash
npm install
```
---
Exemple de `.env` local :
## Règles métier
```env
PORT=5000
NODE_ENV=development
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="change_me"
APP_URL="http://localhost:5000"
FRONTEND_URL="http://localhost:5173"
MAIL_HOST="smtp.example.com"
MAIL_PORT=587
MAIL_USER="user@example.com"
MAIL_PASS="change_me"
MAIL_FROM="Thé Tip Top <user@example.com>"
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
```
- Tous les tickets sont gagnants.
- Un ticket ne peut être utilisé qu'une seule fois.
- Les gains sont associés aux tickets dès leur génération.
- Plusieurs participations n'augmentent pas les chances de remporter le gros lot.
- Un seul gagnant du gros lot peut être enregistré.
- Les gains non récupérés dans les délais peuvent être annulés.
Ces valeurs sont uniquement des exemples.
---
## Prisma
## Auteur
```bash
npx prisma generate
npx prisma migrate dev
npx prisma migrate deploy
npx prisma db seed
npx prisma studio
```
Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale**
## Lancement
Client fictif : **Thé Tip Top**.
Développement :
```bash
npm run dev
```
Démarrage standard :
```bash
npm start
```
API locale généralement disponible sur `http://localhost:5000`.
## Tests
```bash
npm test
```
Les tests Jest/Supertest couvrent notamment l’inscription, la connexion, les autorisations, les participations, les phases du jeu, le tirage final, les notifications, l’authentification Google et des scénarios E2E.
Dans Jenkins, une base PostgreSQL temporaire est créée. Les migrations et le seed sont exécutés avant les tests, puis les résultats sont publiés au format JUnit.
## Documentation API
Swagger UI est accessible localement sur :
```text
http://localhost:5000/api-docs
```
## CI/CD
Le projet utilise Gitea, Jenkins, Docker et un Registry privé.
```text
feature/* → CI uniquement
develop → DEV automatique
release-* → PREPROD automatique
main → validation manuelle → PROD
```
Pipeline :
```text
Checkout
↓
Tests backend + PostgreSQL temporaire
↓
Lint + Build frontend
↓
Docker Build
↓
Registry
↓
Déploiement
↓
Contrôle des conteneurs
```
## Environnements
| Environnement | Frontend | API |
|---|---|---|
| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
## Infrastructure
- Docker / Docker Compose ;
- Gitea ;
- Jenkins ;
- Docker Registry ;
- Traefik ;
- Let's Encrypt / HTTPS ;
- PostgreSQL ;
- Prometheus ;
- Grafana ;
- cAdvisor ;
- Node Exporter.
## Arborescence simplifiée
```text
backend/
├── prisma/
│ ├── migrations/
│ ├── schema.prisma
│ └── seed.js
├── src/
│ ├── config/
│ ├── controllers/
│ ├── docs/
│ ├── middlewares/
│ ├── routes/
│ ├── utils/
│ ├── validations/
│ └── app.js
├── tests/
├── Dockerfile
├── package.json
└── README.md
```
## Projet
Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
Client fictif : **Thé Tip Top**.

View File

@@ -3,6 +3,8 @@ module.exports = {
testMatch: ["**/tests/**/*.test.js"],
setupFiles: ["<rootDir>/tests/setupEnv.js"],
clearMocks: true,
testTimeout: 10000,

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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"))
);

View File

@@ -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?

View File

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

View File

@@ -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: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
<p>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.</p>
également à votre compte sans passer par ${providerLabel}.</p>
<p style="text-align:center;padding:20px 0">
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Définir mon mot de passe
@@ -42,95 +46,126 @@ async function sendPasswordSetupEmail(user) {
</p>
<p style="color:#888;font-size:13px">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.</p>
connexion ${providerLabel} continuera de fonctionner.</p>
</div>
`,
});
}
// 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 };

View File

@@ -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: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
<p>Voici vos identifiants de première connexion :</p>
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
Email : <strong>${email}</strong><br />
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
</p>
<p style="text-align:center;padding:20px 0">
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Me connecter
</a>
</p>
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
plus ensuite.</p>
</div>
`,
});
} 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,

View File

@@ -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: `
<h2>Réinitialisation du mot de passe</h2>
<p>Bonjour ${user.firstname},</p>
<p>Vous avez demandé à définir un nouveau mot de passe.</p>
<p>Cliquez sur le lien ci-dessous pour le choisir :</p>
<a href="${resetUrl}">Choisir mon nouveau mot de passe</a>
<p>Ce lien expire dans 30 minutes.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
`,
});
}
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: `
<h2>Réinitialisation du mot de passe</h2>
<p>Bonjour ${user.firstname},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
<a href="${resetUrl}">Réinitialiser mon mot de passe</a>
<p>Ce lien expire dans 30 minutes.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
`,
});
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,
};

View File

@@ -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,
};

View File

@@ -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.

View File

@@ -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;

View File

@@ -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:

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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[^>]*>([^<]+)<\/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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -15,7 +15,8 @@ const payload = {
firstname: "Camille",
lastname: "Martin",
email: testEmail,
password: "Password123",
password: "Password123!",
confirmPassword: "Password123!",
birthdate: "1990-05-14",
gender: "Femme",
};

View File

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

View File

@@ -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 = "";