merge: integrate Emmanuel changes into main
This commit is contained in:
@@ -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
12
backend/.gitignore
vendored
@@ -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?
|
||||
@@ -3,6 +3,8 @@ module.exports = {
|
||||
|
||||
testMatch: ["**/tests/**/*.test.js"],
|
||||
|
||||
setupFiles: ["<rootDir>/tests/setupEnv.js"],
|
||||
|
||||
clearMocks: true,
|
||||
|
||||
testTimeout: 10000,
|
||||
|
||||
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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"))
|
||||
);
|
||||
@@ -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?
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
34
backend/src/middlewares/captcha.middleware.js
Normal file
34
backend/src/middlewares/captcha.middleware.js
Normal 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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
9
backend/src/utils/frontendUrl.js
Normal file
9
backend/src/utils/frontendUrl.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
11
backend/src/validations/email.validation.js
Normal file
11
backend/src/validations/email.validation.js
Normal 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 };
|
||||
21
backend/src/validations/password.validation.js
Normal file
21
backend/src/validations/password.validation.js
Normal 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 };
|
||||
144
backend/tests/adminDeliveries.test.js
Normal file
144
backend/tests/adminDeliveries.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
75
backend/tests/captcha.middleware.test.js
Normal file
75
backend/tests/captcha.middleware.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
166
backend/tests/employeeOnboarding.test.js
Normal file
166
backend/tests/employeeOnboarding.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
113
backend/tests/facebookLogin.test.js
Normal file
113
backend/tests/facebookLogin.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
167
backend/tests/forgotPassword.test.js
Normal file
167
backend/tests/forgotPassword.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
64
backend/tests/oauthCallback.test.js
Normal file
64
backend/tests/oauthCallback.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,8 @@ const payload = {
|
||||
firstname: "Camille",
|
||||
lastname: "Martin",
|
||||
email: testEmail,
|
||||
password: "Password123",
|
||||
password: "Password123!",
|
||||
confirmPassword: "Password123!",
|
||||
birthdate: "1990-05-14",
|
||||
gender: "Femme",
|
||||
};
|
||||
|
||||
62
backend/tests/registerPassword.test.js
Normal file
62
backend/tests/registerPassword.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
4
backend/tests/setupEnv.js
Normal file
4
backend/tests/setupEnv.js
Normal 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 = "";
|
||||
Reference in New Issue
Block a user