implementation de nodemailer for email validation

This commit is contained in:
2026-06-26 14:01:38 +02:00
parent 45535e2b21
commit baa1b8ccc0
14 changed files with 5235 additions and 24 deletions

View File

@@ -3,3 +3,10 @@ PORT=5000
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="your_secret_key"
APP_URL=http://localhost:5000
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USER=yourmail_adress@gmail.com
MAIL_PASS=_______________________
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"

26
backend/jest.config.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
testEnvironment: "node",
// Dossier des tests
testMatch: ["**/tests/**/*.test.js"],
// Nettoyage des mocks entre les tests
clearMocks: true,
// Timeout (utile pour Prisma / DB)
testTimeout: 10000,
// Affichage des logs de test
verbose: true,
// Couverture de code (optionnel mais bon pour soutenance)
collectCoverage: true,
coverageDirectory: "coverage",
// Fichiers à ignorer
testPathIgnorePatterns: ["/node_modules/"],
// Configuration pour Jest avec Supertest
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"]
};

4773
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,12 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "nodemon src/app.js",
"test": "echo \"Error: no test specified\" && exit 1",
"generate:tickets": "node src/scripts/generateTickets.js"
"dev": "nodemon server.js",
"start": "node server.js",
"generate:tickets": "node src/scripts/generateTickets.js",
"test": "jest --runInBand",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"keywords": [],
"author": "",
@@ -15,16 +18,21 @@
"@prisma/client": "^6.19.3",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.1",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"nodemon": "^3.1.14"
"@types/jest": "^30.0.0",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"jest": "^30.4.2",
"nodemon": "^3.1.14",
"supertest": "^7.2.2"
},
"prisma": {
"seed": "node prisma/seed.js"

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerifyExpires" TIMESTAMP(3),
ADD COLUMN "emailVerifyToken" TEXT,
ADD COLUMN "isEmailVerified" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetPasswordExpires" TIMESTAMP(3),
ADD COLUMN "resetPasswordToken" TEXT;

View File

@@ -36,6 +36,13 @@ model User {
marketingOptIn Boolean @default(false)
createdAt DateTime @default(now())
isEmailVerified Boolean @default(false)
emailVerifyToken String?
emailVerifyExpires DateTime?
resetPasswordToken String?
resetPasswordExpires DateTime?
tickets Ticket[]
participations Participation[]
grandPrizeWinner GrandPrizeWinner?

7
backend/server.js Normal file
View File

@@ -0,0 +1,7 @@
const app = require("./src/app");
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});

View File

@@ -1,12 +1,12 @@
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./routes/auth.routes");
const gameRoutes = require("./routes/game.routes");
const employeeRoutes = require("./routes/employee.routes");
const adminRoutes = require("./routes/admin.routes");
const swaggerUi = require("swagger-ui-express");
const swaggerSpec = require("./docs/swagger");
@@ -15,24 +15,18 @@ const app = express();
app.use(cors());
app.use(express.json());
// routes
app.use("/api/auth", authRoutes);
app.use("/api/game", gameRoutes);
app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes);
app.use(
"/api-docs",
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// swagger
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// test route
app.get("/", (req, res) => {
res.json({
message: "API Thé Tip Top opérationnelle",
});
res.json({ message: "API Thé Tip Top opérationnelle" });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,13 @@
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT),
secure: false,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
});
module.exports = transporter;

View File

@@ -1,6 +1,8 @@
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const prisma = require("../config/prisma");
const transporter = require("../config/mailer");
const register = async (req, res) => {
try {
@@ -32,6 +34,9 @@ const register = async (req, res) => {
const hashedPassword = await bcrypt.hash(password, 10);
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
const user = await prisma.user.create({
data: {
firstname,
@@ -41,6 +46,9 @@ const register = async (req, res) => {
birthdate: birthdate ? new Date(birthdate) : null,
gender,
marketingOptIn: marketingOptIn || false,
isEmailVerified: false,
emailVerifyToken,
emailVerifyExpires,
},
select: {
id: true,
@@ -49,12 +57,28 @@ const register = async (req, res) => {
email: true,
role: true,
marketingOptIn: true,
isEmailVerified: true,
createdAt: true,
},
});
const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Validation de votre compte Thé Tip Top",
html: `
<h2>Bienvenue ${firstname}</h2>
<p>Merci pour votre inscription.</p>
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
<a href="${verifyUrl}">Valider mon compte</a>
<p>Ce lien expire dans 1 heure.</p>
`,
});
return res.status(201).json({
message: "Inscription réussie.",
message: "Inscription réussie. Un email de validation a été envoyé.",
user,
});
} catch (error) {
@@ -65,6 +89,45 @@ const register = async (req, res) => {
}
};
const verifyEmail = async (req, res) => {
try {
const { token } = req.params;
const user = await prisma.user.findFirst({
where: {
emailVerifyToken: token,
emailVerifyExpires: {
gt: new Date(),
},
},
});
if (!user) {
return res.status(400).json({
message: "Lien de validation invalide ou expiré.",
});
}
await prisma.user.update({
where: { id: user.id },
data: {
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
},
});
return res.status(200).json({
message: "Email validé avec succès. Vous pouvez maintenant vous connecter.",
});
} catch (error) {
console.error("Erreur verifyEmail:", error);
return res.status(500).json({
message: "Erreur serveur lors de la validation de l'email.",
});
}
};
const login = async (req, res) => {
try {
const { email, password } = req.body;
@@ -85,6 +148,12 @@ const login = async (req, res) => {
});
}
if (!user.isEmailVerified) {
return res.status(403).json({
message: "Veuillez valider votre adresse email avant de vous connecter.",
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
@@ -121,6 +190,114 @@ const login = async (req, res) => {
}
};
const forgotPassword = async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
message: "Email requis.",
});
}
const user = await prisma.user.findUnique({
where: { email },
});
// 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é.",
});
}
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken,
resetPasswordExpires,
},
});
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${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é.",
});
} catch (error) {
console.error("Erreur forgotPassword:", error);
return res.status(500).json({
message: "Erreur serveur lors de la demande de réinitialisation.",
});
}
};
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password } = 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 user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
resetPasswordExpires: {
gt: new Date(),
},
},
});
if (!user) {
return res.status(400).json({
message: "Lien de réinitialisation invalide ou expiré.",
});
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
return res.status(200).json({
message: "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.",
});
} catch (error) {
console.error("Erreur resetPassword:", error);
return res.status(500).json({
message: "Erreur serveur lors de la réinitialisation du mot de passe.",
});
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
@@ -131,4 +308,7 @@ module.exports = {
register,
login,
me,
verifyEmail,
resetPassword,
forgotPassword
};

View File

@@ -1,5 +1,5 @@
const express = require("express");
const { register, login, me } = require("../controllers/auth.controller");
const { register, login, verifyEmail, forgotPassword, resetPassword, me } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
@@ -63,7 +63,99 @@ router.post("/login", validate(loginSchema), login);
* 200:
* description: Profil utilisateur
*/
router.get("/me", authMiddleware, me);
/**
* @swagger
* /api/auth/verify-email/{token}:
* get:
* summary: Vérification de l'adresse email
* tags:
* - Auth
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* description: Token de validation envoyé par email
* responses:
* 200:
* description: Email vérifié avec succès.
* 400:
* description: Lien de validation invalide ou expiré.
* 500:
* description: Erreur serveur.
*/
router.get("/verify-email/:token",verifyEmail);
/**
* @swagger
* /api/auth/forgot-password:
* post:
* summary: Demande de réinitialisation du mot de passe
* tags:
* - Auth
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* properties:
* email:
* type: string
* format: email
* example: jean.dupont@gmail.com
* responses:
* 200:
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
* 400:
* description: Email requis.
* 500:
* description: Erreur serveur.
*/
router.post("/forgot-password", forgotPassword);
/**
* @swagger
* /api/auth/reset-password/{token}:
* post:
* summary: Réinitialisation du mot de passe
* tags:
* - Auth
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* description: Token de réinitialisation reçu par email
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - password
* properties:
* password:
* type: string
* format: password
* example: NouveauPassword123
* responses:
* 200:
* description: Mot de passe réinitialisé avec succès.
* 400:
* description: Lien invalide, expiré ou mot de passe invalide.
* 500:
* description: Erreur serveur.
*/
router.post("/reset-password/:token",resetPassword);
module.exports = router;

View File

@@ -0,0 +1,89 @@
const request = require("supertest");
const app = require("../src/app");
describe("THE TIP TOP - FULL E2E FLOW", () => {
let token;
let ticketCode = "ABC123";
// ------------------------
// 1. REGISTER
// ------------------------
// Test de l'inscription d'un utilisateur
test("Register user", async () => {
const res = await request(app)
.post("/api/auth/register")
.send({
firstname: "Test",
lastname: "User",
email: "test@test.com",
password: "Password123"
});
// Vérification du code de statut attendu (201 pour succès, 409 si l'utilisateur existe déjà)
expect([201, 409]).toContain(res.statusCode);
});
// ------------------------
// 2. LOGIN
// ------------------------
// Test de la connexion d'un utilisateur
test("Login user", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({
email: "test@test.com",
password: "Password123"
});
// Vérification du code de statut attendu (200 pour succès)
expect(res.statusCode).toBe(200);
expect(res.body.token).toBeDefined();
token = res.body.token;
});
// ------------------------
// 3. PARTICIPATE GAME
// ------------------------
// Test de la participation à un jeu
test("Participate to game", async () => {
const res = await request(app)
.post("/api/game/participate")
.set("Authorization", `Bearer ${token}`)
.send({ code: ticketCode });
// Vérification du code de statut attendu
expect([200, 201, 404, 409, 400]).toContain(res.statusCode);
});
// ------------------------
// 4. MY GAINS
// ------------------------
// Test de la récupération des gains d'un utilisateur
test("Get my gains", async () => {
const res = await request(app)
.get("/api/game/my-gains")
.set("Authorization", `Bearer ${token}`);
// Vérification du code de statut attendu
expect(res.statusCode).toBe(200);
expect(res.body.gains).toBeDefined();
});
// ------------------------
// 5. ADMIN STATS
// ------------------------
test("Admin stats - CLIENT access denied", async () => {
const res = await request(app)
.get("/api/admin/stats")
.set("Authorization", `Bearer ${token}`);
expect(res.statusCode).toBe(403);
expect(res.body.message || res.body).toBeDefined();
});
});
// Nous avons validé un flux E2E complet garantissant le bon fonctionnement global du système. La couverture actuelle est partielle et peut être enrichie pour couvrir les cas limites et les erreurs métier.

12
backend/tests/setup.js Normal file
View File

@@ -0,0 +1,12 @@
const prisma = require("../src/config/prisma");
beforeAll(async () => {
await prisma.claim.deleteMany();
await prisma.participation.deleteMany();
await prisma.ticket.deleteMany();
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});