view = $this->makeTwigMock(); $this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class); $this->authService = $this->createMock(AuthServiceInterface::class); $this->flash = $this->createMock(FlashServiceInterface::class); $this->clientIpResolver = new ClientIpResolver(['*']); // Par défaut : IP non verrouillée $this->authService->method('checkRateLimit')->willReturn(0); $this->controller = new PasswordResetController( $this->view, $this->passwordResetService, $this->authService, $this->flash, $this->clientIpResolver, self::BASE_URL, ); } // ── showForgot ─────────────────────────────────────────────────── /** * showForgot() doit rendre le formulaire de demande de réinitialisation. */ public function testShowForgotRendersForm(): void { $this->view->expects($this->once()) ->method('render') ->with($this->anything(), 'pages/auth/password-forgot.twig', $this->anything()) ->willReturnArgument(0); $res = $this->controller->showForgot($this->makeGet('/password/forgot'), $this->makeResponse()); $this->assertStatus($res, 200); } // ── forgot — rate limiting ──────────────────────────────────────── /** * forgot() doit rediriger avec une erreur générique si l'IP est verrouillée. * * Le message ne mentionne pas l'email pour ne pas révéler qu'une demande * pour cette adresse a déjà été traitée. * * Note : le mock setUp() est reconfiguré ici via un nouveau mock pour éviter * le conflit avec le willReturn(0) global — en PHPUnit 11 la première * configuration prend la main sur les suivantes pour le même matcher any(). */ public function testForgotRedirectsWhenRateLimited(): void { $authService = $this->createMock(AuthServiceInterface::class); $authService->expects($this->once()) ->method('checkRateLimit') ->with('203.0.113.5') ->willReturn(10); $controller = new PasswordResetController( $this->view, $this->passwordResetService, $authService, $this->flash, $this->clientIpResolver, self::BASE_URL, ); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('Trop de demandes')); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', ]); $res = $controller->forgot($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * forgot() ne doit pas appeler requestReset() si l'IP est verrouillée. */ public function testForgotDoesNotCallServiceWhenRateLimited(): void { $authService = $this->createMock(AuthServiceInterface::class); $authService->expects($this->once()) ->method('checkRateLimit') ->with('203.0.113.5') ->willReturn(5); $controller = new PasswordResetController( $this->view, $this->passwordResetService, $authService, $this->flash, $this->clientIpResolver, self::BASE_URL, ); $this->passwordResetService->expects($this->never())->method('requestReset'); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', ]); $controller->forgot($req, $this->makeResponse()); } /** * forgot() doit enregistrer une tentative pour chaque demande, quel que soit le résultat. * * Réinitialiser le compteur uniquement en cas de succès constituerait un canal * caché permettant de déduire si l'adresse email est enregistrée. */ public function testForgotAlwaysRecordsFailure(): void { $this->authService->expects($this->once()) ->method('recordFailure') ->with('203.0.113.5'); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', ]); $this->controller->forgot($req, $this->makeResponse()); } /** * forgot() ne doit jamais réinitialiser le compteur de rate limit. * * Un reset sur succès révélerait l'existence du compte (canal caché). */ public function testForgotNeverResetsRateLimit(): void { $this->authService->expects($this->never())->method('resetRateLimit'); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $this->controller->forgot($req, $this->makeResponse()); } // ── forgot — comportement nominal ──────────────────────────────── /** * forgot() doit afficher un message de succès générique même si l'email est inconnu. * * Protection contre l'énumération des comptes : le comportement externe * ne doit pas révéler si l'adresse est enregistrée. */ public function testForgotAlwaysShowsGenericSuccessMessage(): void { // requestReset() est void — aucun stub nécessaire, le mock ne lève pas d'exception par défaut $this->flash->expects($this->once())->method('set') ->with('reset_success', $this->stringContains('Si cette adresse')); $req = $this->makePost('/password/forgot', ['email' => 'unknown@example.com']); $res = $this->controller->forgot($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * forgot() doit afficher une erreur et rediriger si l'envoi d'email échoue. */ public function testForgotRedirectsWithErrorOnMailFailure(): void { $this->passwordResetService->method('requestReset') ->willThrowException(new \RuntimeException('SMTP error')); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('erreur est survenue')); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $res = $this->controller->forgot($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * forgot() doit transmettre l'APP_URL au service lors de la demande. */ public function testForgotPassesBaseUrlToService(): void { $this->passwordResetService->expects($this->once()) ->method('requestReset') ->with($this->anything(), self::BASE_URL); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $this->controller->forgot($req, $this->makeResponse()); } // ── showReset ──────────────────────────────────────────────────── /** * showReset() doit rediriger avec une erreur si le paramètre token est absent. */ public function testShowResetRedirectsWhenTokenMissing(): void { $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('manquant')); $res = $this->controller->showReset($this->makeGet('/password/reset'), $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * showReset() doit rediriger avec une erreur si le token est invalide ou expiré. */ public function testShowResetRedirectsWhenTokenInvalid(): void { $this->passwordResetService->method('validateToken')->willReturn(null); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('invalide')); $req = $this->makeGet('/password/reset', ['token' => 'invalid-token']); $res = $this->controller->showReset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * showReset() doit rendre le formulaire si le token est valide. */ public function testShowResetRendersFormWhenTokenValid(): void { $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); $this->passwordResetService->method('validateToken')->willReturn($user); $this->view->expects($this->once()) ->method('render') ->with($this->anything(), 'pages/auth/password-reset.twig', $this->anything()) ->willReturnArgument(0); $req = $this->makeGet('/password/reset', ['token' => 'valid-token']); $res = $this->controller->showReset($req, $this->makeResponse()); $this->assertStatus($res, 200); } // ── reset ──────────────────────────────────────────────────────── /** * reset() doit rediriger vers /password/forgot si le token est absent du corps. */ public function testResetRedirectsToForgotWhenTokenMissing(): void { $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('manquant')); $req = $this->makePost('/password/reset', [ 'token' => '', 'new_password' => 'newpass123', 'new_password_confirm' => 'newpass123', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/forgot'); } /** * reset() doit rediriger avec une erreur si les mots de passe ne correspondent pas. */ public function testResetRedirectsWhenPasswordMismatch(): void { $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('correspondent pas')); $req = $this->makePost('/password/reset', [ 'token' => 'abc123', 'new_password' => 'newpass1', 'new_password_confirm' => 'newpass2', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/reset?token=abc123'); } /** * reset() doit rediriger avec une erreur si le nouveau mot de passe est trop court. */ public function testResetRedirectsOnWeakPassword(): void { $this->passwordResetService->method('resetPassword') ->willThrowException(new WeakPasswordException()); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('8 caractères')); $req = $this->makePost('/password/reset', [ 'token' => 'abc123', 'new_password' => 'short', 'new_password_confirm' => 'short', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/reset?token=abc123'); } /** * reset() doit rediriger avec une erreur si le token est invalide ou expiré. */ public function testResetRedirectsOnInvalidToken(): void { $this->passwordResetService->method('resetPassword') ->willThrowException(new InvalidResetTokenException('Token invalide')); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('invalide')); $req = $this->makePost('/password/reset', [ 'token' => 'expired-token', 'new_password' => 'newpass123', 'new_password_confirm' => 'newpass123', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/reset?token=expired-token'); } /** * reset() doit rediriger avec une erreur générique en cas d'exception inattendue. */ public function testResetRedirectsOnUnexpectedError(): void { $this->passwordResetService->method('resetPassword') ->willThrowException(new \RuntimeException('DB error')); $this->flash->expects($this->once())->method('set') ->with('reset_error', $this->stringContains('inattendue')); $req = $this->makePost('/password/reset', [ 'token' => 'abc123', 'new_password' => 'newpass123', 'new_password_confirm' => 'newpass123', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/password/reset?token=abc123'); } /** * reset() doit flasher un succès et rediriger vers /auth/login en cas de succès. */ public function testResetRedirectsToLoginOnSuccess(): void { $this->flash->expects($this->once())->method('set') ->with('login_success', $this->stringContains('réinitialisé')); $req = $this->makePost('/password/reset', [ 'token' => 'valid-token', 'new_password' => 'newpass123', 'new_password_confirm' => 'newpass123', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertRedirectTo($res, '/auth/login'); } /** * reset() doit encoder correctement le token dans l'URL de redirection en cas d'erreur. */ public function testResetEncodesTokenInRedirectUrl(): void { $this->passwordResetService->method('resetPassword') ->willThrowException(new WeakPasswordException()); $this->flash->method('set'); $token = 'tok/en+with=special&chars'; $req = $this->makePost('/password/reset', [ 'token' => $token, 'new_password' => 'x', 'new_password_confirm' => 'x', ]); $res = $this->controller->reset($req, $this->makeResponse()); $this->assertSame( '/password/reset?token=' . urlencode($token), $res->getHeaderLine('Location'), ); } }