feat: Réinitialisation de mot de passe avec tokens sécurisés
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5): Backend: - Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique - Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP) - Endpoint POST /api/password/reset avec validation token - Templates email (demande + confirmation) - Repository Redis avec TTL 2h pour distinguer expiré/invalide Frontend: - Page /mot-de-passe-oublie avec message générique (anti-énumération) - Page /reset-password/[token] avec validation temps réel des critères - Gestion erreurs: token invalide, expiré, déjà utilisé Tests: - 14 tests unitaires PasswordResetToken - 7 tests unitaires RequestPasswordResetHandler - 7 tests unitaires ResetPasswordHandler - Tests E2E Playwright pour le flux complet
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\RequestPasswordReset;
|
||||
|
||||
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetCommand;
|
||||
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetHandler;
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class RequestPasswordResetHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryPasswordResetTokenRepository $tokenRepository;
|
||||
private Clock $clock;
|
||||
/** @var DomainEvent[] */
|
||||
private array $dispatchedEvents = [];
|
||||
private RequestPasswordResetHandler $handler;
|
||||
private TenantId $tenantId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public DateTimeImmutable $now;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock);
|
||||
|
||||
$this->dispatchedEvents = [];
|
||||
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
|
||||
/** @param DomainEvent[] $events */
|
||||
public function __construct(private array &$events)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
$this->events[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$this->handler = new RequestPasswordResetHandler(
|
||||
$this->userRepository,
|
||||
$this->tokenRepository,
|
||||
$this->clock,
|
||||
$eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesTokenWhenUserExists(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Verify token was created
|
||||
$token = $this->tokenRepository->findValidTokenForUser((string) $user->id);
|
||||
self::assertNotNull($token);
|
||||
self::assertSame(self::EMAIL, $token->email);
|
||||
self::assertTrue($token->tenantId->equals($this->tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesTokenGeneratedEvent(): void
|
||||
{
|
||||
$this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
self::assertCount(1, $this->dispatchedEvents);
|
||||
self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSilentlySucceedsWhenUserDoesNotExist(): void
|
||||
{
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: 'nonexistent@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
// Should NOT throw - silently succeeds
|
||||
($this->handler)($command);
|
||||
|
||||
// No events dispatched
|
||||
self::assertCount(0, $this->dispatchedEvents);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSilentlySucceedsWhenEmailIsInvalid(): void
|
||||
{
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: 'not-an-email',
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
// Should NOT throw - silently succeeds
|
||||
($this->handler)($command);
|
||||
|
||||
// No events dispatched
|
||||
self::assertCount(0, $this->dispatchedEvents);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReusesExistingValidToken(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
// First request - creates token
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
$firstToken = $this->tokenRepository->findValidTokenForUser((string) $user->id);
|
||||
self::assertNotNull($firstToken);
|
||||
|
||||
// Clear dispatched events
|
||||
$this->dispatchedEvents = [];
|
||||
|
||||
// Second request - should NOT create new token
|
||||
($this->handler)($command);
|
||||
|
||||
// Same token should exist
|
||||
$secondToken = $this->tokenRepository->findValidTokenForUser((string) $user->id);
|
||||
self::assertNotNull($secondToken);
|
||||
self::assertSame($firstToken->tokenValue, $secondToken->tokenValue);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesNewTokenWhenExistingTokenIsExpired(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
// Create an expired token manually
|
||||
$expiredToken = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: new DateTimeImmutable('2026-01-28 08:00:00'), // 2 hours ago
|
||||
);
|
||||
$this->tokenRepository->save($expiredToken);
|
||||
|
||||
// findValidTokenForUser should return null for expired tokens
|
||||
$validToken = $this->tokenRepository->findValidTokenForUser((string) $user->id);
|
||||
self::assertNull($validToken);
|
||||
|
||||
// Now request a new token
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// A new token should be created
|
||||
self::assertCount(1, $this->dispatchedEvents);
|
||||
self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotGenerateTokenForUserInDifferentTenant(): void
|
||||
{
|
||||
// Create user in tenant 1
|
||||
$this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
// Request reset for different tenant
|
||||
$differentTenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440099');
|
||||
$command = new RequestPasswordResetCommand(
|
||||
email: self::EMAIL,
|
||||
tenantId: $differentTenantId,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// No events dispatched (user not found in different tenant)
|
||||
self::assertCount(0, $this->dispatchedEvents);
|
||||
}
|
||||
|
||||
private function createAndSaveUser(string $email): User
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: Role::PROF,
|
||||
tenantId: $this->tenantId,
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: new DateTimeImmutable('1990-01-01'),
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
// Activate user so they can request password reset
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
$user->activer(
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
at: $this->clock->now(),
|
||||
consentementPolicy: $consentementPolicy,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ResetPassword;
|
||||
|
||||
use App\Administration\Application\Command\ResetPassword\ResetPasswordCommand;
|
||||
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Administration\Domain\Event\PasswordResetTokenUsed;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class ResetPasswordHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string NEW_PASSWORD = 'NewSecurePassword123!';
|
||||
public const string HASHED_PASSWORD = '$argon2id$newhashedpassword';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryPasswordResetTokenRepository $tokenRepository;
|
||||
private InMemoryRefreshTokenRepository $refreshTokenRepository;
|
||||
private Clock $clock;
|
||||
/** @var DomainEvent[] */
|
||||
private array $dispatchedEvents = [];
|
||||
private ResetPasswordHandler $handler;
|
||||
private TenantId $tenantId;
|
||||
private PasswordHasher $passwordHasher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public DateTimeImmutable $now;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock);
|
||||
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
|
||||
|
||||
$this->passwordHasher = new class implements PasswordHasher {
|
||||
#[Override]
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return ResetPasswordHandlerTest::HASHED_PASSWORD;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$this->dispatchedEvents = [];
|
||||
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
|
||||
/** @param DomainEvent[] $events */
|
||||
public function __construct(private array &$events)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
$this->events[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$this->handler = new ResetPasswordHandler(
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->refreshTokenRepository,
|
||||
$this->passwordHasher,
|
||||
$this->clock,
|
||||
$eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResetsPasswordWithValidToken(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Verify password was updated
|
||||
$updatedUser = $this->userRepository->get($user->id);
|
||||
self::assertSame(self::HASHED_PASSWORD, $updatedUser->hashedPassword);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesPasswordChangedEvent(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Should have MotDePasseChange and PasswordResetTokenUsed events
|
||||
$passwordChangedEvents = array_filter(
|
||||
$this->dispatchedEvents,
|
||||
static fn ($e) => $e instanceof MotDePasseChange
|
||||
);
|
||||
self::assertCount(1, $passwordChangedEvents);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesTokenUsedEvent(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
$tokenUsedEvents = array_filter(
|
||||
$this->dispatchedEvents,
|
||||
static fn ($e) => $e instanceof PasswordResetTokenUsed
|
||||
);
|
||||
self::assertCount(1, $tokenUsedEvents);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTokenNotFound(): void
|
||||
{
|
||||
$command = new ResetPasswordCommand(
|
||||
token: 'nonexistent-token',
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(PasswordResetTokenNotFoundException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTokenExpired(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
|
||||
// Create an expired token (2 hours ago)
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: new DateTimeImmutable('2026-01-28 07:00:00'), // 3 hours ago
|
||||
);
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(PasswordResetTokenExpiredException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
// First use succeeds
|
||||
($this->handler)($command);
|
||||
|
||||
// Second attempt with same token should fail with "already used"
|
||||
// (token remains in storage until TTL expiry to preserve this error semantic)
|
||||
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itKeepsUsedTokenInStorageToPreserveErrorSemantics(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Token should remain in storage (marked as used) until TTL expiry
|
||||
// This allows distinguishing "already used" (410) from "invalid" (400)
|
||||
$foundToken = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
self::assertNotNull($foundToken);
|
||||
self::assertTrue($foundToken->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itInvalidatesAllUserSessionsAfterPasswordReset(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser(self::EMAIL);
|
||||
$token = $this->createAndSaveToken($user);
|
||||
|
||||
// Create active refresh tokens for the user (simulating active sessions)
|
||||
$refreshToken = RefreshToken::create(
|
||||
userId: $user->id,
|
||||
tenantId: $this->tenantId,
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
issuedAt: $this->clock->now(),
|
||||
);
|
||||
$this->refreshTokenRepository->save($refreshToken);
|
||||
|
||||
// Verify user has active sessions before reset
|
||||
self::assertTrue($this->refreshTokenRepository->hasActiveSessionsForUser($user->id));
|
||||
|
||||
$command = new ResetPasswordCommand(
|
||||
token: $token->tokenValue,
|
||||
newPassword: self::NEW_PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// All sessions should be invalidated after password reset (AC3)
|
||||
self::assertFalse($this->refreshTokenRepository->hasActiveSessionsForUser($user->id));
|
||||
}
|
||||
|
||||
private function createAndSaveUser(string $email): User
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: Role::PROF,
|
||||
tenantId: $this->tenantId,
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: new DateTimeImmutable('1990-01-01'),
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
// Activate user
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
$user->activer(
|
||||
hashedPassword: '$argon2id$oldhashed',
|
||||
at: $this->clock->now(),
|
||||
consentementPolicy: $consentementPolicy,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createAndSaveToken(User $user): PasswordResetToken
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: (string) $user->id,
|
||||
email: self::EMAIL,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
// Drain events to avoid counting generation event
|
||||
$token->pullDomainEvents();
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -64,12 +64,12 @@ final class ActivationTokenTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenValueIsUuidV4Format(): void
|
||||
public function tokenValueIsUuidV7Format(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
|
||||
'/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
|
||||
$token->tokenValue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\PasswordResetToken;
|
||||
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use App\Administration\Domain\Event\PasswordResetTokenUsed;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PasswordResetTokenTest extends TestCase
|
||||
{
|
||||
private TenantId $tenantId;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenantId = TenantId::generate();
|
||||
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesANewToken(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(PasswordResetTokenId::class, $token->id);
|
||||
self::assertNotEmpty($token->tokenValue);
|
||||
self::assertSame('user-123', $token->userId);
|
||||
self::assertSame('user@example.com', $token->email);
|
||||
self::assertTrue($token->tenantId->equals($this->tenantId));
|
||||
self::assertSame($this->now, $token->createdAt);
|
||||
self::assertNull($token->usedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExpiresAfterOneHour(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$expectedExpiresAt = $this->now->modify('+1 hour');
|
||||
self::assertEquals($expectedExpiresAt, $token->expiresAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsNotExpiredBeforeOneHour(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$fiftyNineMinutesLater = $this->now->modify('+59 minutes');
|
||||
self::assertFalse($token->isExpired($fiftyNineMinutesLater));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsExpiredAfterOneHour(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$oneHourLater = $this->now->modify('+1 hour');
|
||||
self::assertTrue($token->isExpired($oneHourLater));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsGenerationEvent(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(PasswordResetTokenGenerated::class, $events[0]);
|
||||
|
||||
/** @var PasswordResetTokenGenerated $event */
|
||||
$event = $events[0];
|
||||
self::assertTrue($event->tokenId->equals($token->id));
|
||||
self::assertSame('user-123', $event->userId);
|
||||
self::assertSame('user@example.com', $event->email);
|
||||
self::assertTrue($event->tenantId->equals($this->tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeUsedWhenValid(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$token->pullDomainEvents(); // Clear generation event
|
||||
|
||||
$useAt = $this->now->modify('+30 minutes');
|
||||
$token->use($useAt);
|
||||
|
||||
self::assertSame($useAt, $token->usedAt);
|
||||
self::assertTrue($token->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsUsedEventWhenConsumed(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$token->pullDomainEvents(); // Clear generation event
|
||||
|
||||
$useAt = $this->now->modify('+30 minutes');
|
||||
$token->use($useAt);
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(PasswordResetTokenUsed::class, $events[0]);
|
||||
|
||||
/** @var PasswordResetTokenUsed $event */
|
||||
$event = $events[0];
|
||||
self::assertTrue($event->tokenId->equals($token->id));
|
||||
self::assertSame('user-123', $event->userId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCannotBeUsedTwice(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$useAt = $this->now->modify('+30 minutes');
|
||||
$token->use($useAt);
|
||||
|
||||
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
|
||||
|
||||
$token->use($useAt->modify('+1 minute'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCannotBeUsedWhenExpired(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$this->expectException(PasswordResetTokenExpiredException::class);
|
||||
|
||||
$twoHoursLater = $this->now->modify('+2 hours');
|
||||
$token->use($twoHoursLater);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itValidatesForUseWithoutConsuming(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$thirtyMinutesLater = $this->now->modify('+30 minutes');
|
||||
|
||||
// Should not throw
|
||||
$token->validateForUse($thirtyMinutesLater);
|
||||
|
||||
// Token should NOT be marked as used
|
||||
self::assertFalse($token->isUsed());
|
||||
self::assertNull($token->usedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateForUseThrowsWhenExpired(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$this->expectException(PasswordResetTokenExpiredException::class);
|
||||
|
||||
$twoHoursLater = $this->now->modify('+2 hours');
|
||||
$token->validateForUse($twoHoursLater);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateForUseThrowsWhenAlreadyUsed(): void
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
);
|
||||
|
||||
$token->use($this->now->modify('+30 minutes'));
|
||||
|
||||
$this->expectException(PasswordResetTokenAlreadyUsedException::class);
|
||||
|
||||
$token->validateForUse($this->now->modify('+31 minutes'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeReconstitutedFromStorage(): void
|
||||
{
|
||||
$id = PasswordResetTokenId::generate();
|
||||
$tokenValue = 'abc-123-def-456';
|
||||
$expiresAt = $this->now->modify('+1 hour');
|
||||
$usedAt = $this->now->modify('+30 minutes');
|
||||
|
||||
$token = PasswordResetToken::reconstitute(
|
||||
id: $id,
|
||||
tokenValue: $tokenValue,
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
expiresAt: $expiresAt,
|
||||
usedAt: $usedAt,
|
||||
);
|
||||
|
||||
self::assertTrue($token->id->equals($id));
|
||||
self::assertSame($tokenValue, $token->tokenValue);
|
||||
self::assertSame('user-123', $token->userId);
|
||||
self::assertSame('user@example.com', $token->email);
|
||||
self::assertTrue($token->tenantId->equals($this->tenantId));
|
||||
self::assertSame($this->now, $token->createdAt);
|
||||
self::assertEquals($expiresAt, $token->expiresAt);
|
||||
self::assertSame($usedAt, $token->usedAt);
|
||||
self::assertTrue($token->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteDoesNotRecordEvents(): void
|
||||
{
|
||||
$token = PasswordResetToken::reconstitute(
|
||||
id: PasswordResetTokenId::generate(),
|
||||
tokenValue: 'abc-123',
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: $this->now,
|
||||
expiresAt: $this->now->modify('+1 hour'),
|
||||
usedAt: null,
|
||||
);
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
|
||||
self::assertCount(0, $events);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user