From 52e6e2c14cef5e0d1abae268f68a283ad334f0bf Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 19 Mar 2026 12:13:32 +0100 Subject: [PATCH] Add email verification, organizer approval, and forgot password features - Add isVerified, emailVerificationToken, emailVerifiedAt fields to User entity - Send verification email on registration with token link - Add /verification-email/{token} route to confirm email - Send notification emails to organizer and staff on organizer email verification - Add isApproved and offer fields to User entity for organizer approval workflow - Auto-verify and auto-approve SSO Keycloak users with offer='custom' - Add resetCode and resetCodeExpiresAt fields to User entity - Create ForgotPasswordController with 2-step flow (email -> code + new password) - Block forgot password for SSO users (no local password) - Add "Mot de passe oublie" link on login page - Create email templates: verification, reset_code, organizer_pending, organizer_request - Add migrations for all new fields - Add tests: ForgotPasswordControllerTest (9 tests), update RegistrationControllerTest, update UserTest with verification, approval, offer, and reset code fields Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/Version20260319110454.php | 35 ++++ migrations/Version20260319110647.php | 35 ++++ migrations/Version20260319111127.php | 35 ++++ src/Controller/ForgotPasswordController.php | 126 +++++++++++++ src/Controller/RegistrationController.php | 74 +++++++- src/Entity/User.php | 105 +++++++++++ src/Security/KeycloakAuthenticator.php | 8 + templates/email/organizer_pending.html.twig | 10 + templates/email/organizer_request.html.twig | 46 +++++ templates/email/reset_code.html.twig | 12 ++ templates/email/verification.html.twig | 12 ++ templates/security/forgot_password.html.twig | 81 ++++++++ templates/security/login.html.twig | 4 + .../ForgotPasswordControllerTest.php | 176 ++++++++++++++++++ .../Controller/RegistrationControllerTest.php | 81 +++++++- tests/Entity/UserTest.php | 48 +++++ 16 files changed, 886 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20260319110454.php create mode 100644 migrations/Version20260319110647.php create mode 100644 migrations/Version20260319111127.php create mode 100644 src/Controller/ForgotPasswordController.php create mode 100644 templates/email/organizer_pending.html.twig create mode 100644 templates/email/organizer_request.html.twig create mode 100644 templates/email/reset_code.html.twig create mode 100644 templates/email/verification.html.twig create mode 100644 templates/security/forgot_password.html.twig create mode 100644 tests/Controller/ForgotPasswordControllerTest.php diff --git a/migrations/Version20260319110454.php b/migrations/Version20260319110454.php new file mode 100644 index 0000000..fe6c60c --- /dev/null +++ b/migrations/Version20260319110454.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT FALSE'); + $this->addSql('ALTER TABLE "user" ADD email_verification_token VARCHAR(64) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD email_verified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP is_verified'); + $this->addSql('ALTER TABLE "user" DROP email_verification_token'); + $this->addSql('ALTER TABLE "user" DROP email_verified_at'); + } +} diff --git a/migrations/Version20260319110647.php b/migrations/Version20260319110647.php new file mode 100644 index 0000000..e732d04 --- /dev/null +++ b/migrations/Version20260319110647.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE "user" ADD is_approved BOOLEAN NOT NULL DEFAULT FALSE'); + $this->addSql('ALTER TABLE "user" ADD offer VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ALTER is_verified DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP is_approved'); + $this->addSql('ALTER TABLE "user" DROP offer'); + $this->addSql('ALTER TABLE "user" ALTER is_verified SET DEFAULT false'); + } +} diff --git a/migrations/Version20260319111127.php b/migrations/Version20260319111127.php new file mode 100644 index 0000000..501f5d6 --- /dev/null +++ b/migrations/Version20260319111127.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE "user" ADD reset_code VARCHAR(6) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD reset_code_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ALTER is_approved DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP reset_code'); + $this->addSql('ALTER TABLE "user" DROP reset_code_expires_at'); + $this->addSql('ALTER TABLE "user" ALTER is_approved SET DEFAULT false'); + } +} diff --git a/src/Controller/ForgotPasswordController.php b/src/Controller/ForgotPasswordController.php new file mode 100644 index 0000000..da1d945 --- /dev/null +++ b/src/Controller/ForgotPasswordController.php @@ -0,0 +1,126 @@ +request->getString('step', 'email'); + $email = $request->request->getString('email', $request->getSession()->get('reset_email', '')); + + if ($request->isMethod('POST')) { + if ('email' === $step) { + return $this->handleEmailStep($request, $em, $mailerService, $email); + } + + if ('code' === $step) { + return $this->handleCodeStep($request, $em, $passwordHasher, $email); + } + } + + return $this->render('security/forgot_password.html.twig', [ + 'step' => 'email', + 'email' => '', + ]); + } + + private function handleEmailStep(Request $request, EntityManagerInterface $em, MailerService $mailerService, string $email): Response + { + $email = trim($email); + + if ('' === $email) { + $this->addFlash('error', 'Veuillez saisir votre adresse email.'); + + return $this->render('security/forgot_password.html.twig', [ + 'step' => 'email', + 'email' => $email, + ]); + } + + $user = $em->getRepository(User::class)->findOneBy(['email' => $email]); + + if ($user && null === $user->getKeycloakId()) { + $code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT); + $user->setResetCode($code); + $user->setResetCodeExpiresAt(new \DateTimeImmutable(sprintf('+%d minutes', self::CODE_EXPIRATION_MINUTES))); + $em->flush(); + + $html = $this->renderView('email/reset_code.html.twig', [ + 'firstName' => $user->getFirstName(), + 'code' => $code, + 'expirationMinutes' => self::CODE_EXPIRATION_MINUTES, + ]); + + $mailerService->sendEmail( + to: $email, + subject: 'Votre code de reinitialisation - E-Ticket', + content: $html, + withUnsubscribe: false, + ); + } + + $request->getSession()->set('reset_email', $email); + $this->addFlash('success', 'Si un compte existe avec cet email, un code vous a ete envoye.'); + + return $this->render('security/forgot_password.html.twig', [ + 'step' => 'code', + 'email' => $email, + ]); + } + + private function handleCodeStep(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, string $email): Response + { + $code = trim($request->request->getString('code')); + $password = $request->request->getString('password'); + + if ('' === $code || '' === $password) { + $this->addFlash('error', 'Veuillez remplir tous les champs.'); + + return $this->render('security/forgot_password.html.twig', [ + 'step' => 'code', + 'email' => $email, + ]); + } + + $user = $em->getRepository(User::class)->findOneBy([ + 'email' => $email, + 'resetCode' => $code, + ]); + + if (!$user || !$user->getResetCodeExpiresAt() || $user->getResetCodeExpiresAt() < new \DateTimeImmutable()) { + $this->addFlash('error', 'Code invalide ou expire.'); + + return $this->render('security/forgot_password.html.twig', [ + 'step' => 'code', + 'email' => $email, + ]); + } + + $user->setPassword($passwordHasher->hashPassword($user, $password)); + $user->setResetCode(null); + $user->setResetCodeExpiresAt(null); + $em->flush(); + + $request->getSession()->remove('reset_email'); + $this->addFlash('success', 'Mot de passe modifie avec succes. Vous pouvez vous connecter.'); + + return $this->redirectToRoute('app_login'); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 783453c..1e09ca7 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -3,12 +3,14 @@ namespace App\Controller; use App\Entity\User; +use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; class RegistrationController extends AbstractController @@ -19,6 +21,7 @@ class RegistrationController extends AbstractController UserPasswordHasherInterface $passwordHasher, EntityManagerInterface $em, ValidatorInterface $validator, + MailerService $mailerService, ): Response { if ($this->getUser()) { return $this->redirectToRoute('app_account'); @@ -44,11 +47,31 @@ class RegistrationController extends AbstractController $user->setPhone(trim($request->request->getString('phone'))); } + $token = bin2hex(random_bytes(32)); + $user->setEmailVerificationToken($token); + $errors = $validator->validate($user); if (0 === count($errors)) { $em->persist($user); $em->flush(); - $this->addFlash('success', 'Compte créé avec succès ! Connectez-vous.'); + + $verificationUrl = $this->generateUrl('app_verify_email', [ + 'token' => $token, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $this->renderView('email/verification.html.twig', [ + 'firstName' => $user->getFirstName(), + 'verificationUrl' => $verificationUrl, + ]); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Verifiez votre adresse email - E-Ticket', + content: $html, + withUnsubscribe: false, + ); + + $this->addFlash('success', 'Compte cree ! Un email de verification vous a ete envoye.'); return $this->redirectToRoute('app_login'); } @@ -65,4 +88,53 @@ class RegistrationController extends AbstractController ], ]); } + + #[Route('/verification-email/{token}', name: 'app_verify_email')] + public function verifyEmail(string $token, EntityManagerInterface $em, MailerService $mailerService): Response + { + $user = $em->getRepository(User::class)->findOneBy(['emailVerificationToken' => $token]); + + if (!$user) { + $this->addFlash('error', 'Lien de verification invalide ou expire.'); + + return $this->redirectToRoute('app_login'); + } + + $user->setIsVerified(true); + $user->setEmailVerifiedAt(new \DateTimeImmutable()); + $user->setEmailVerificationToken(null); + $em->flush(); + + if (\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Votre compte organisateur est en cours d\'examen - E-Ticket', + content: $this->renderView('email/organizer_pending.html.twig', [ + 'firstName' => $user->getFirstName(), + ]), + withUnsubscribe: false, + ); + + $mailerService->sendEmail( + to: 'contact@e-cosplay.fr', + subject: sprintf('Nouvelle demande organisateur : %s %s', $user->getFirstName(), $user->getLastName()), + content: $this->renderView('email/organizer_request.html.twig', [ + 'firstName' => $user->getFirstName(), + 'lastName' => $user->getLastName(), + 'email' => $user->getEmail(), + 'companyName' => $user->getCompanyName(), + 'siret' => $user->getSiret(), + 'address' => $user->getAddress(), + 'postalCode' => $user->getPostalCode(), + 'city' => $user->getCity(), + 'phone' => $user->getPhone(), + ]), + withUnsubscribe: false, + ); + } + + $this->addFlash('success', 'Votre adresse email a ete verifiee. Vous pouvez vous connecter.'); + + return $this->redirectToRoute('app_login'); + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index c277d96..22d4033 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -55,6 +55,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 20, nullable: true)] private ?string $phone = null; + #[ORM\Column(length: 6, nullable: true)] + private ?string $resetCode = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $resetCodeExpiresAt = null; + + #[ORM\Column] + private bool $isVerified = false; + + #[ORM\Column] + private bool $isApproved = false; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $offer = null; + + #[ORM\Column(length: 64, nullable: true)] + private ?string $emailVerificationToken = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $emailVerifiedAt = null; + #[ORM\Column] private \DateTimeImmutable $createdAt; @@ -215,6 +236,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getResetCode(): ?string + { + return $this->resetCode; + } + + public function setResetCode(?string $resetCode): static + { + $this->resetCode = $resetCode; + + return $this; + } + + public function getResetCodeExpiresAt(): ?\DateTimeImmutable + { + return $this->resetCodeExpiresAt; + } + + public function setResetCodeExpiresAt(?\DateTimeImmutable $resetCodeExpiresAt): static + { + $this->resetCodeExpiresAt = $resetCodeExpiresAt; + + return $this; + } + + public function isApproved(): bool + { + return $this->isApproved; + } + + public function setIsApproved(bool $isApproved): static + { + $this->isApproved = $isApproved; + + return $this; + } + + public function getOffer(): ?string + { + return $this->offer; + } + + public function setOffer(?string $offer): static + { + $this->offer = $offer; + + return $this; + } + + public function isVerified(): bool + { + return $this->isVerified; + } + + public function setIsVerified(bool $isVerified): static + { + $this->isVerified = $isVerified; + + return $this; + } + + public function getEmailVerificationToken(): ?string + { + return $this->emailVerificationToken; + } + + public function setEmailVerificationToken(?string $emailVerificationToken): static + { + $this->emailVerificationToken = $emailVerificationToken; + + return $this; + } + + public function getEmailVerifiedAt(): ?\DateTimeImmutable + { + return $this->emailVerifiedAt; + } + + public function setEmailVerifiedAt(?\DateTimeImmutable $emailVerifiedAt): static + { + $this->emailVerifiedAt = $emailVerifiedAt; + + return $this; + } + public function getKeycloakId(): ?string { return $this->keycloakId; diff --git a/src/Security/KeycloakAuthenticator.php b/src/Security/KeycloakAuthenticator.php index e18cacf..313abc1 100644 --- a/src/Security/KeycloakAuthenticator.php +++ b/src/Security/KeycloakAuthenticator.php @@ -64,6 +64,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator if ($existingUser) { $existingUser->setKeycloakId($keycloakId); $existingUser->setRoles($roles); + $existingUser->setIsVerified(true); + $existingUser->setIsApproved(true); + $existingUser->setOffer('custom'); + $existingUser->setEmailVerifiedAt($existingUser->getEmailVerifiedAt() ?? new \DateTimeImmutable()); $this->em->flush(); return $existingUser; @@ -76,6 +80,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator $newUser->setLastName($data['family_name'] ?? ''); $newUser->setPassword(''); $newUser->setRoles($roles); + $newUser->setIsVerified(true); + $newUser->setIsApproved(true); + $newUser->setOffer('custom'); + $newUser->setEmailVerifiedAt(new \DateTimeImmutable()); $this->em->persist($newUser); $this->em->flush(); diff --git a/templates/email/organizer_pending.html.twig b/templates/email/organizer_pending.html.twig new file mode 100644 index 0000000..b99ea55 --- /dev/null +++ b/templates/email/organizer_pending.html.twig @@ -0,0 +1,10 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Votre compte organisateur est en cours d'examen{% endblock %} + +{% block content %} +

Bonjour {{ firstName }} !

+

Votre adresse email a ete verifiee avec succes.

+

Votre demande de compte organisateur est maintenant en cours d'examen par l'equipe E-Ticket. Nous allons analyser votre dossier et vous informerons de notre decision dans les plus brefs delais.

+

Si vous avez des questions, n'hesitez pas a nous contacter a contact@e-cosplay.fr

+{% endblock %} diff --git a/templates/email/organizer_request.html.twig b/templates/email/organizer_request.html.twig new file mode 100644 index 0000000..ac576a1 --- /dev/null +++ b/templates/email/organizer_request.html.twig @@ -0,0 +1,46 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Nouvelle demande de compte organisateur{% endblock %} + +{% block content %} +

Nouvelle demande organisateur

+

Un nouveau compte organisateur vient de verifier son email et attend votre approbation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChampValeur
Nom{{ lastName }}
Prenom{{ firstName }}
Email{{ email }}
Raison sociale{{ companyName }}
SIRET{{ siret }}
Adresse{{ address }}, {{ postalCode }} {{ city }}
Telephone{{ phone }}
+{% endblock %} diff --git a/templates/email/reset_code.html.twig b/templates/email/reset_code.html.twig new file mode 100644 index 0000000..1e539a8 --- /dev/null +++ b/templates/email/reset_code.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Code de reinitialisation{% endblock %} + +{% block content %} +

Bonjour {{ firstName }} !

+

Vous avez demande la reinitialisation de votre mot de passe. Voici votre code de verification :

+
+ {{ code }} +
+

Ce code est valable {{ expirationMinutes }} minutes. Si vous n'avez pas fait cette demande, ignorez cet email.

+{% endblock %} diff --git a/templates/email/verification.html.twig b/templates/email/verification.html.twig new file mode 100644 index 0000000..d9b04dd --- /dev/null +++ b/templates/email/verification.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Verifiez votre adresse email{% endblock %} + +{% block content %} +

Bienvenue {{ firstName }} !

+

Merci de vous etre inscrit sur E-Ticket. Pour activer votre compte, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.

+

+ Verifier mon email +

+

Si vous n'avez pas cree de compte sur E-Ticket, vous pouvez ignorer cet email.

+{% endblock %} diff --git a/templates/security/forgot_password.html.twig b/templates/security/forgot_password.html.twig new file mode 100644 index 0000000..fcee751 --- /dev/null +++ b/templates/security/forgot_password.html.twig @@ -0,0 +1,81 @@ +{% extends 'base.html.twig' %} + +{% block title %}Mot de passe oublie - E-Ticket{% endblock %} +{% block description %}Reinitialisation de votre mot de passe E-Ticket{% endblock %} + +{% block body %} +
+

Mot de passe oublie

+

+ {% if step == 'email' %} + Saisissez votre email pour recevoir un code. + {% else %} + Entrez le code recu par email et votre nouveau mot de passe. + {% endif %} +

+ + {% for message in app.flashes('success') %} +
+

{{ message }}

+
+ {% endfor %} + + {% for message in app.flashes('error') %} +
+

{{ message }}

+
+ {% endfor %} + + {% if step == 'email' %} +
+ +
+ + +
+
+ +
+
+ {% else %} +
+ + +
+ + +
+
+ + +
+
+ +
+
+ {% endif %} + +
+

Retour a la connexion

+
+
+{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 01cdf44..e62106a 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -38,6 +38,10 @@ placeholder="••••••••"> +
+ Mot de passe oublie ? +
+
diff --git a/tests/Controller/ForgotPasswordControllerTest.php b/tests/Controller/ForgotPasswordControllerTest.php new file mode 100644 index 0000000..d2ebcc1 --- /dev/null +++ b/tests/Controller/ForgotPasswordControllerTest.php @@ -0,0 +1,176 @@ +request('GET', '/mot-de-passe-oublie'); + + self::assertResponseIsSuccessful(); + } + + public function testEmailStepWithEmptyEmail(): void + { + $client = static::createClient(); + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'email', + 'email' => '', + ]); + + self::assertResponseIsSuccessful(); + } + + public function testEmailStepSendsCode(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-forgot-'.uniqid().'@example.com'); + $user->setFirstName('Test'); + $user->setLastName('User'); + $user->setPassword('$2y$13$hashed'); + $em->persist($user); + $em->flush(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'email', + 'email' => $user->getEmail(), + ]); + + self::assertResponseIsSuccessful(); + + $em->refresh($user); + self::assertNotNull($user->getResetCode()); + self::assertNotNull($user->getResetCodeExpiresAt()); + } + + public function testEmailStepIgnoresSsoUser(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-sso-'.uniqid().'@example.com'); + $user->setFirstName('SSO'); + $user->setLastName('User'); + $user->setPassword(''); + $user->setKeycloakId('kc-'.uniqid()); + $em->persist($user); + $em->flush(); + + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'email', + 'email' => $user->getEmail(), + ]); + + self::assertResponseIsSuccessful(); + + $em->refresh($user); + self::assertNull($user->getResetCode()); + } + + public function testEmailStepWithUnknownEmailDoesNotReveal(): void + { + $client = static::createClient(); + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'email', + 'email' => 'unknown-'.uniqid().'@example.com', + ]); + + self::assertResponseIsSuccessful(); + } + + public function testCodeStepWithEmptyFields(): void + { + $client = static::createClient(); + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'code', + 'email' => 'test@example.com', + 'code' => '', + 'password' => '', + ]); + + self::assertResponseIsSuccessful(); + } + + public function testCodeStepWithInvalidCode(): void + { + $client = static::createClient(); + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'code', + 'email' => 'test@example.com', + 'code' => '000000', + 'password' => 'NewPassword123!', + ]); + + self::assertResponseIsSuccessful(); + } + + public function testCodeStepWithExpiredCode(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-expired-'.uniqid().'@example.com'); + $user->setFirstName('Test'); + $user->setLastName('User'); + $user->setPassword('$2y$13$hashed'); + $user->setResetCode('123456'); + $user->setResetCodeExpiresAt(new \DateTimeImmutable('-1 hour')); + $em->persist($user); + $em->flush(); + + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'code', + 'email' => $user->getEmail(), + 'code' => '123456', + 'password' => 'NewPassword123!', + ]); + + self::assertResponseIsSuccessful(); + } + + public function testCodeStepWithValidCodeResetsPassword(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-reset-'.uniqid().'@example.com'); + $user->setFirstName('Test'); + $user->setLastName('User'); + $user->setPassword('$2y$13$hashed'); + $user->setResetCode('654321'); + $user->setResetCodeExpiresAt(new \DateTimeImmutable('+15 minutes')); + $em->persist($user); + $em->flush(); + + $client->request('POST', '/mot-de-passe-oublie', [ + 'step' => 'code', + 'email' => $user->getEmail(), + 'code' => '654321', + 'password' => 'NewPassword123!', + ]); + + self::assertResponseRedirects('/connexion'); + + $em->refresh($user); + self::assertNull($user->getResetCode()); + self::assertNull($user->getResetCodeExpiresAt()); + self::assertNotSame('$2y$13$hashed', $user->getPassword()); + } +} diff --git a/tests/Controller/RegistrationControllerTest.php b/tests/Controller/RegistrationControllerTest.php index cb2bc61..d51060b 100644 --- a/tests/Controller/RegistrationControllerTest.php +++ b/tests/Controller/RegistrationControllerTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Controller; use App\Entity\User; +use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -35,9 +36,14 @@ class RegistrationControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } - public function testRegistrationWithValidData(): void + public function testRegistrationWithValidDataSendsVerificationEmail(): void { $client = static::createClient(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + $client->request('POST', '/inscription', [ 'first_name' => 'Jean', 'last_name' => 'Dupont', @@ -51,6 +57,11 @@ class RegistrationControllerTest extends WebTestCase public function testRegistrationAsOrganizer(): void { $client = static::createClient(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + $client->request('POST', '/inscription', [ 'type' => 'organizer', 'first_name' => 'Marie', @@ -73,6 +84,10 @@ class RegistrationControllerTest extends WebTestCase $email = 'duplicate-'.uniqid().'@example.com'; $client = static::createClient(); + + $mailer = $this->createMock(MailerService::class); + static::getContainer()->set(MailerService::class, $mailer); + $client->request('POST', '/inscription', [ 'first_name' => 'Jean', 'last_name' => 'Dupont', @@ -89,4 +104,68 @@ class RegistrationControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } + + public function testVerifyEmailWithValidToken(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-verify-'.uniqid().'@example.com'); + $user->setFirstName('Test'); + $user->setLastName('User'); + $user->setPassword('$2y$13$hashed'); + $user->setEmailVerificationToken('valid-token-'.uniqid()); + $em->persist($user); + $em->flush(); + + $token = $user->getEmailVerificationToken(); + $client->request('GET', '/verification-email/'.$token); + + self::assertResponseRedirects('/connexion'); + + $em->refresh($user); + self::assertTrue($user->isVerified()); + self::assertNotNull($user->getEmailVerifiedAt()); + self::assertNull($user->getEmailVerificationToken()); + } + + public function testVerifyEmailOrganizerSendsNotificationEmails(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::exactly(2))->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $user = new User(); + $user->setEmail('test-orga-verify-'.uniqid().'@example.com'); + $user->setFirstName('Marie'); + $user->setLastName('Martin'); + $user->setPassword('$2y$13$hashed'); + $user->setRoles(['ROLE_ORGANIZER']); + $user->setCompanyName('Mon Asso'); + $user->setSiret('12345678901234'); + $user->setAddress('12 rue de la Paix'); + $user->setPostalCode('75001'); + $user->setCity('Paris'); + $user->setPhone('0612345678'); + $user->setEmailVerificationToken('orga-token-'.uniqid()); + $em->persist($user); + $em->flush(); + + $token = $user->getEmailVerificationToken(); + $client->request('GET', '/verification-email/'.$token); + + self::assertResponseRedirects('/connexion'); + } + + public function testVerifyEmailWithInvalidToken(): void + { + $client = static::createClient(); + $client->request('GET', '/verification-email/invalid-token'); + + self::assertResponseRedirects('/connexion'); + } } diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index 1174a4a..6a7785e 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -88,6 +88,54 @@ class UserTest extends TestCase self::assertNull($user->getPhone()); } + public function testResetCodeFields(): void + { + $user = new User(); + + self::assertNull($user->getResetCode()); + self::assertNull($user->getResetCodeExpiresAt()); + + $expiry = new \DateTimeImmutable('+15 minutes'); + $result = $user->setResetCode('123456')->setResetCodeExpiresAt($expiry); + + self::assertSame($user, $result); + self::assertSame('123456', $user->getResetCode()); + self::assertSame($expiry, $user->getResetCodeExpiresAt()); + } + + public function testApprovalAndOfferFields(): void + { + $user = new User(); + + self::assertFalse($user->isApproved()); + self::assertNull($user->getOffer()); + + $result = $user->setIsApproved(true)->setOffer('custom'); + + self::assertSame($user, $result); + self::assertTrue($user->isApproved()); + self::assertSame('custom', $user->getOffer()); + } + + public function testEmailVerificationFields(): void + { + $user = new User(); + + self::assertFalse($user->isVerified()); + self::assertNull($user->getEmailVerificationToken()); + self::assertNull($user->getEmailVerifiedAt()); + + $now = new \DateTimeImmutable(); + $result = $user->setIsVerified(true) + ->setEmailVerificationToken('abc123') + ->setEmailVerifiedAt($now); + + self::assertSame($user, $result); + self::assertTrue($user->isVerified()); + self::assertSame('abc123', $user->getEmailVerificationToken()); + self::assertSame($now, $user->getEmailVerifiedAt()); + } + public function testEraseCredentialsDoesNotThrow(): void { $user = new User();