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) <noreply@anthropic.com>
This commit is contained in:
176
tests/Controller/ForgotPasswordControllerTest.php
Normal file
176
tests/Controller/ForgotPasswordControllerTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
class ForgotPasswordControllerTest extends WebTestCase
|
||||
{
|
||||
public function testPageReturnsSuccess(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user