Files
netslim-core/tests/Identity/PasswordResetServiceTest.php
2026-03-20 22:13:41 +01:00

342 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetServiceTest extends TestCase
{
/** @var PasswordResetRepositoryInterface&MockObject */
private PasswordResetRepositoryInterface $resetRepository;
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var MailServiceInterface&MockObject */
private MailServiceInterface $mailService;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private PasswordResetApplicationService $service;
/** @var TransactionManagerInterface&MockObject */
private TransactionManagerInterface $transactionManager;
protected function setUp(): void
{
$this->resetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->transactionManager = $this->createMock(TransactionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new PasswordResetApplicationService(
new RequestPasswordReset(
$this->resetRepository,
$this->userRepository,
$this->mailService,
new PasswordResetTokenPolicy(),
$this->loginAttemptRepository,
new LoginRateLimitPolicy(),
),
new ValidatePasswordResetToken($this->resetRepository, $this->userRepository),
new ResetPassword(
$this->resetRepository,
$this->userRepository,
$this->transactionManager,
new PasswordPolicy(),
),
);
}
public function testRequestResetUnknownEmailReturnsSilently(): void
{
$this->userRepository->method('findByEmail')->willReturn(null);
$this->mailService->expects($this->never())->method('send');
$this->resetRepository->expects($this->never())->method('create');
$this->service->requestReset('inconnu@example.com', 'https://app.exemple.com');
}
public function testRequestResetInvalidatesPreviousTokens(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId')
->with($user->getId());
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetCreatesTokenInDatabase(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create')
->with($user->getId(), $this->callback('is_string'), $this->callback('is_string'));
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetSendsEmailWithCorrectAddress(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send')
->with(
'alice@example.com',
$this->callback('is_string'),
'@Identity/emails/password-reset.twig',
$this->callback('is_array'),
);
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetUrlContainsToken(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->callback(function (array $context): bool {
return isset($context['resetUrl'])
&& str_contains($context['resetUrl'], '/password/reset?token=');
}),
);
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testValidateTokenMissingToken(): void
{
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->willReturn(null);
$result = $this->service->validateToken('tokeninexistant');
$this->assertNull($result);
}
public function testValidateTokenExpiredToken(): void
{
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 1,
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() - 3600),
'used_at' => null,
]);
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
public function testValidateTokenValidToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$result = $this->service->validateToken($tokenRaw);
$this->assertSame($user, $result);
}
public function testValidateTokenDeletedUserReturnsNull(): void
{
$tokenRaw = 'token-valide-mais-user-supprime';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with(999)
->willReturn(null);
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
public function testResetPasswordInvalidToken(): void
{
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())->method('consumeActiveToken')->willReturn(null);
$this->expectException(InvalidResetTokenException::class);
$this->expectExceptionMessageMatches('/invalide ou a expiré/');
$this->service->resetPassword('tokeninvalide', 'nouveaumdp12');
}
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void
{
$this->expectException(WeakPasswordException::class);
$this->service->resetPassword('montokenbrut', '12345678901');
}
public function testResetPasswordPreservesWhitespace(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$newPassword = ' nouveaumdp12 ';
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())
->method('consumeActiveToken')
->with($tokenHash, $this->callback('is_string'))
->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with($user->getId(), $this->callback(static function (string $hash) use ($newPassword): bool {
return password_verify($newPassword, $hash)
&& !password_verify(trim($newPassword), $hash);
}));
$this->service->resetPassword($tokenRaw, $newPassword);
}
public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())
->method('consumeActiveToken')
->with($tokenHash, $this->callback('is_string'))
->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with($user->getId(), $this->callback('is_string'));
$this->service->resetPassword($tokenRaw, 'nouveaumdp12');
}
private function makeUser(): User
{
return new User(
1,
'alice',
'alice@example.com',
password_hash('motdepasse12', PASSWORD_BCRYPT),
);
}
}