Files
slim-blog/tests/Auth/PasswordResetControllerTest.php
2026-03-16 02:33:18 +01:00

411 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthServiceInterface;
use App\Auth\PasswordResetController;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour PasswordResetController.
*
* Couvre le flux en deux étapes :
* 1. showForgot() / forgot() — demande de réinitialisation
* 2. showReset() / reset() — saisie du nouveau mot de passe
*
* Points clés :
* - forgot() vérifie le rate limit par IP avant tout traitement
* - forgot() enregistre systématiquement une tentative (anti-canal-caché)
* - forgot() affiche toujours un message de succès générique (anti-énumération)
* - showReset() valide le token avant d'afficher le formulaire
* - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var PasswordResetServiceInterface&MockObject */
private PasswordResetServiceInterface $passwordResetService;
/** @var AuthServiceInterface&MockObject */
private AuthServiceInterface $authService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private PasswordResetController $controller;
private const BASE_URL = 'https://example.com';
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
$this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
// 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,
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->method('checkRateLimit')->willReturn(10);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
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']);
$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->method('checkRateLimit')->willReturn(5);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
self::BASE_URL,
);
$this->passwordResetService->expects($this->never())->method('requestReset');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$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');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$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'),
);
}
}