resetRepository = $this->createMock(PasswordResetRepositoryInterface::class); $this->userRepository = $this->createMock(UserRepositoryInterface::class); $this->mailService = $this->createMock(MailServiceInterface::class); $this->db = $this->createMock(PDO::class); $this->service = new PasswordResetService( $this->resetRepository, $this->userRepository, $this->mailService, $this->db, ); } // ── requestReset ─────────────────────────────────────────────── /** * requestReset() avec un email inconnu ne doit ni envoyer d'email * ni lever d'exception (protection contre l'énumération d'emails). */ 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://blog.exemple.com'); } /** * requestReset() doit invalider les tokens précédents avant d'en créer un nouveau. */ 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->method('create'); $this->mailService->method('send'); $this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); } /** * requestReset() doit persister un nouveau token en base. */ public function testRequestResetCreatesTokenInDatabase(): void { $user = $this->makeUser(); $this->userRepository->method('findByEmail')->willReturn($user); $this->resetRepository->method('invalidateByUserId'); $this->resetRepository->expects($this->once()) ->method('create') ->with($user->getId(), $this->callback('is_string'), $this->callback('is_string')); $this->mailService->method('send'); $this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); } /** * requestReset() doit envoyer un email avec le bon destinataire et template. */ public function testRequestResetSendsEmailWithCorrectAddress(): void { $user = $this->makeUser(); $this->userRepository->method('findByEmail')->willReturn($user); $this->resetRepository->method('invalidateByUserId'); $this->resetRepository->method('create'); $this->mailService->expects($this->once()) ->method('send') ->with( 'alice@example.com', $this->callback('is_string'), 'emails/password-reset.twig', $this->callback('is_array'), ); $this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); } /** * L'URL de réinitialisation dans le contexte de l'email doit contenir le token brut. */ public function testRequestResetUrlContainsToken(): void { $user = $this->makeUser(); $this->userRepository->method('findByEmail')->willReturn($user); $this->resetRepository->method('invalidateByUserId'); $this->resetRepository->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://blog.exemple.com'); } // ── validateToken ────────────────────────────────────────────── /** * validateToken() avec un token inexistant doit retourner null. */ public function testValidateTokenMissingToken(): void { $this->resetRepository->method('findActiveByHash')->willReturn(null); $result = $this->service->validateToken('tokeninexistant'); $this->assertNull($result); } /** * validateToken() avec un token expiré doit retourner null. */ 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); } /** * validateToken() avec un token valide doit retourner l'utilisateur associé. */ 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); } // ── resetPassword ────────────────────────────────────────────── /** * resetPassword() avec un token invalide doit lever une InvalidArgumentException. */ /** * validateToken() doit retourner null si le token est valide mais l'utilisateur a été supprimé. */ public function testValidateTokenDeletedUserReturnsNull(): void { $row = [ 'user_id' => 999, 'expires_at' => date('Y-m-d H:i:s', time() + 3600), 'used_at' => null, ]; $this->resetRepository->expects($this->once())->method('findActiveByHash')->willReturn($row); $this->userRepository->expects($this->once())->method('findById')->with(999)->willReturn(null); $result = $this->service->validateToken('token-valide-mais-user-supprime'); $this->assertNull($result); } public function testResetPasswordInvalidToken(): void { $this->db->method('beginTransaction')->willReturn(true); $this->db->method('inTransaction')->willReturn(true); $this->db->expects($this->once())->method('rollBack'); $this->resetRepository->method('consumeActiveToken')->willReturn(null); $this->expectException(InvalidResetTokenException::class); $this->expectExceptionMessageMatches('/invalide ou a expiré/'); $this->service->resetPassword('tokeninvalide', 'nouveaumdp1'); } /** * resetPassword() avec un mot de passe trop court doit lever WeakPasswordException. */ public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void { $user = $this->makeUser(); $tokenRaw = 'montokenbrut'; $tokenHash = hash('sha256', $tokenRaw); $this->resetRepository->method('findActiveByHash')->willReturn([ 'user_id' => $user->getId(), 'token_hash' => $tokenHash, 'expires_at' => date('Y-m-d H:i:s', time() + 3600), 'used_at' => null, ]); $this->userRepository->method('findById')->willReturn($user); $this->expectException(WeakPasswordException::class); $this->service->resetPassword($tokenRaw, '1234567'); } /** * resetPassword() doit mettre à jour le mot de passe et marquer le token comme consommé. */ public function testResetPasswordUpdatesPasswordAndConsumesToken(): void { $user = $this->makeUser(); $tokenRaw = 'montokenbrut'; $tokenHash = hash('sha256', $tokenRaw); $this->db->method('beginTransaction')->willReturn(true); $this->db->method('inTransaction')->willReturn(true); $this->db->expects($this->once())->method('commit'); $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->method('findById')->willReturn($user); $this->userRepository->expects($this->once()) ->method('updatePassword') ->with($user->getId(), $this->callback('is_string')); $this->service->resetPassword($tokenRaw, 'nouveaumdp1'); } // ── Helpers ──────────────────────────────────────────────────── /** * Crée un utilisateur de test standard. */ private function makeUser(): User { return new User( 1, 'alice', 'alice@example.com', password_hash('motdepasse1', PASSWORD_BCRYPT), ); } }