434 lines
16 KiB
PHP
434 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\ClientIpResolver;
|
|
use App\Shared\Http\FlashServiceInterface;
|
|
use App\User\Exception\WeakPasswordException;
|
|
use App\User\User;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use Tests\ControllerTestBase;
|
|
|
|
/**
|
|
* 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 ControllerTestBase
|
|
{
|
|
/** @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 ClientIpResolver $clientIpResolver;
|
|
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);
|
|
$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'),
|
|
);
|
|
}
|
|
}
|