first commit
This commit is contained in:
207
tests/Auth/AccountControllerTest.php
Normal file
207
tests/Auth/AccountControllerTest.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\AccountController;
|
||||
use App\Auth\AuthServiceInterface;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AccountController.
|
||||
*
|
||||
* Couvre showChangePassword() et changePassword() :
|
||||
* mots de passe non identiques, mot de passe faible, mot de passe actuel
|
||||
* incorrect, erreur inattendue et succès.
|
||||
*/
|
||||
final class AccountControllerTest extends ControllerTestCase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var AuthServiceInterface&MockObject */
|
||||
private AuthServiceInterface $authService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private AccountController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
|
||||
$this->controller = new AccountController(
|
||||
$this->view,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
$this->sessionManager,
|
||||
);
|
||||
}
|
||||
|
||||
// ── showChangePassword ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showChangePassword() doit rendre le formulaire de changement de mot de passe.
|
||||
*/
|
||||
public function testShowChangePasswordRendersForm(): void
|
||||
{
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), 'pages/account/password-change.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showChangePassword($this->makeGet('/account/password'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── changePassword ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* changePassword() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testChangePasswordRedirectsWhenPasswordMismatch(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', 'Les mots de passe ne correspondent pas');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'oldpass',
|
||||
'new_password' => 'newpass1',
|
||||
'new_password_confirm' => 'newpass2',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() ne doit pas appeler authService si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testChangePasswordDoesNotCallServiceOnMismatch(): void
|
||||
{
|
||||
$this->authService->expects($this->never())->method('changePassword');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'aaa',
|
||||
'new_password_confirm' => 'bbb',
|
||||
]);
|
||||
$this->controller->changePassword($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur si le nouveau mot de passe est trop court.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnWeakPassword(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')->willThrowException(new WeakPasswordException());
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', $this->stringContains('8 caractères'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'short',
|
||||
'new_password_confirm' => 'short',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur si le mot de passe actuel est incorrect.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnWrongCurrentPassword(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')
|
||||
->willThrowException(new \InvalidArgumentException('Mot de passe actuel incorrect'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', 'Le mot de passe actuel est incorrect');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'wrong',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur générique en cas d'exception inattendue.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnUnexpectedError(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')
|
||||
->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', $this->stringContains('inattendue'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher un message de succès et rediriger en cas de succès.
|
||||
*/
|
||||
public function testChangePasswordRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_success', $this->stringContains('Mot de passe modifié'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'oldpass123',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit utiliser 0 comme userId de repli si la session est vide.
|
||||
*/
|
||||
public function testChangePasswordUsesZeroAsUserIdFallback(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())
|
||||
->method('changePassword')
|
||||
->with(0, $this->anything(), $this->anything());
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$this->controller->changePassword($req, $this->makeResponse());
|
||||
}
|
||||
}
|
||||
177
tests/Auth/AuthControllerTest.php
Normal file
177
tests/Auth/AuthControllerTest.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\AuthController;
|
||||
use App\Auth\AuthServiceInterface;
|
||||
use App\Shared\Http\ClientIpResolver;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\User\User;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AuthController.
|
||||
*
|
||||
* Couvre showLogin(), login() et logout() sans passer par le routeur Slim.
|
||||
* AuthService, FlashService et Twig sont mockés — aucune session PHP,
|
||||
* aucune base de données, aucun serveur HTTP n'est requis.
|
||||
*/
|
||||
final class AuthControllerTest extends ControllerTestCase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var AuthServiceInterface&MockObject */
|
||||
private AuthServiceInterface $authService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
private AuthController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
|
||||
$this->controller = new AuthController(
|
||||
$this->view,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
new ClientIpResolver(['*']),
|
||||
);
|
||||
}
|
||||
|
||||
// ── showLogin ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showLogin() doit rediriger vers /admin/posts si l'utilisateur est déjà connecté.
|
||||
*/
|
||||
public function testShowLoginRedirectsWhenAlreadyLoggedIn(): void
|
||||
{
|
||||
$this->authService->method('isLoggedIn')->willReturn(true);
|
||||
|
||||
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/posts');
|
||||
}
|
||||
|
||||
/**
|
||||
* showLogin() doit rendre le formulaire de connexion si l'utilisateur n'est pas connecté.
|
||||
*/
|
||||
public function testShowLoginRendersFormWhenNotLoggedIn(): void
|
||||
{
|
||||
$this->authService->method('isLoggedIn')->willReturn(false);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), 'pages/auth/login.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── login ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* login() doit rediriger avec un message flash si l'IP est verrouillée.
|
||||
*/
|
||||
public function testLoginRedirectsWhenRateLimited(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(5);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', $this->stringContains('Trop de tentatives'));
|
||||
|
||||
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit conjuguer correctement le singulier/pluriel dans le message de rate limit.
|
||||
*/
|
||||
public function testLoginRateLimitMessageIsSingularForOneMinute(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', $this->logicalNot($this->stringContains('minutes')));
|
||||
|
||||
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
$this->controller->login($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit enregistrer l'échec et rediriger si les identifiants sont invalides.
|
||||
*/
|
||||
public function testLoginRecordsFailureOnInvalidCredentials(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())->method('recordFailure');
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', 'Identifiants invalides');
|
||||
|
||||
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'wrong']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit ouvrir la session et rediriger vers /admin/posts en cas de succès.
|
||||
*/
|
||||
public function testLoginRedirectsToAdminOnSuccess(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn($user);
|
||||
|
||||
$this->authService->expects($this->once())->method('resetRateLimit');
|
||||
$this->authService->expects($this->once())->method('login')->with($user);
|
||||
|
||||
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'secret']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/posts');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit utiliser '0.0.0.0' comme IP de repli si REMOTE_ADDR est absent.
|
||||
*/
|
||||
public function testLoginFallsBackToDefaultIpWhenRemoteAddrMissing(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())
|
||||
->method('checkRateLimit')
|
||||
->with('0.0.0.0');
|
||||
|
||||
$req = $this->makePost('/auth/login');
|
||||
$this->controller->login($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
// ── logout ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* logout() doit détruire la session et rediriger vers l'accueil.
|
||||
*/
|
||||
public function testLogoutDestroysSessionAndRedirects(): void
|
||||
{
|
||||
$this->authService->expects($this->once())->method('logout');
|
||||
|
||||
$res = $this->controller->logout($this->makePost('/auth/logout'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/');
|
||||
}
|
||||
}
|
||||
220
tests/Auth/AuthServiceRateLimitTest.php
Normal file
220
tests/Auth/AuthServiceRateLimitTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\LoginAttemptRepositoryInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\User\UserRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour la protection anti-brute force de AuthService.
|
||||
*
|
||||
* Vérifie le comportement de checkRateLimit(), recordFailure() et
|
||||
* resetRateLimit(). Les constantes testées correspondent aux valeurs
|
||||
* définies dans AuthService :
|
||||
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
|
||||
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
|
||||
*/
|
||||
final class AuthServiceRateLimitTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
/** @var LoginAttemptRepositoryInterface&MockObject */
|
||||
private LoginAttemptRepositoryInterface $loginAttemptRepository;
|
||||
|
||||
private AuthService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
|
||||
|
||||
$this->service = new AuthService(
|
||||
$this->userRepository,
|
||||
$this->sessionManager,
|
||||
$this->loginAttemptRepository,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── checkRateLimit ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si l'IP n'a aucune entrée en base.
|
||||
*/
|
||||
public function testCheckRateLimitUnknownIpReturnsZero(): void
|
||||
{
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si l'IP a des tentatives
|
||||
* mais n'est pas encore verrouillée (locked_until = null).
|
||||
*/
|
||||
public function testCheckRateLimitNoLockReturnsZero(): void
|
||||
{
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 3,
|
||||
'locked_until' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner le nombre de minutes restantes
|
||||
* si l'IP est actuellement verrouillée.
|
||||
*/
|
||||
public function testCheckRateLimitLockedIpReturnsRemainingMinutes(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() + 10 * 60);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertGreaterThan(0, $result);
|
||||
$this->assertLessThanOrEqual(15, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner au minimum 1 minute
|
||||
* même si le verrouillage expire dans quelques secondes.
|
||||
*/
|
||||
public function testCheckRateLimitReturnsAtLeastOneMinute(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() + 30);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si le verrouillage est expiré.
|
||||
*/
|
||||
public function testCheckRateLimitExpiredLockReturnsZero(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() - 60);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit toujours appeler deleteExpired() avant la vérification.
|
||||
*/
|
||||
public function testCheckRateLimitCallsDeleteExpired(): void
|
||||
{
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('deleteExpired');
|
||||
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
|
||||
|
||||
$this->service->checkRateLimit('192.168.1.1');
|
||||
}
|
||||
|
||||
|
||||
// ── recordFailure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* recordFailure() doit déléguer avec MAX_ATTEMPTS = 5 et LOCK_MINUTES = 15.
|
||||
*/
|
||||
public function testRecordFailureDelegatesWithConstants(): void
|
||||
{
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('recordFailure')
|
||||
->with('192.168.1.1', 5, 15);
|
||||
|
||||
$this->service->recordFailure('192.168.1.1');
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit transmettre l'adresse IP exacte au dépôt.
|
||||
*/
|
||||
public function testRecordFailurePassesIpAddress(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('recordFailure')
|
||||
->with($ip, $this->anything(), $this->anything());
|
||||
|
||||
$this->service->recordFailure($ip);
|
||||
}
|
||||
|
||||
|
||||
// ── resetRateLimit ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* resetRateLimit() doit appeler resetForIp() avec l'adresse IP fournie.
|
||||
*/
|
||||
public function testResetRateLimitCallsResetForIp(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('resetForIp')
|
||||
->with($ip);
|
||||
|
||||
$this->service->resetRateLimit($ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* resetRateLimit() ne doit pas appeler recordFailure() ni deleteExpired().
|
||||
*/
|
||||
public function testResetRateLimitCallsNothingElse(): void
|
||||
{
|
||||
$this->loginAttemptRepository->expects($this->never())->method('recordFailure');
|
||||
$this->loginAttemptRepository->expects($this->never())->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('resetForIp');
|
||||
|
||||
$this->service->resetRateLimit('192.168.1.1');
|
||||
}
|
||||
}
|
||||
244
tests/Auth/AuthServiceTest.php
Normal file
244
tests/Auth/AuthServiceTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\LoginAttemptRepositoryInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use App\User\User;
|
||||
use App\User\UserRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AuthService.
|
||||
*
|
||||
* Vérifie l'authentification, le changement de mot de passe et la gestion
|
||||
* des sessions. La création de comptes est couverte par UserServiceTest.
|
||||
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
|
||||
* isoler le service.
|
||||
*/
|
||||
final class AuthServiceTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
/** @var LoginAttemptRepositoryInterface&MockObject */
|
||||
private LoginAttemptRepositoryInterface $loginAttemptRepository;
|
||||
|
||||
private AuthService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
|
||||
|
||||
$this->service = new AuthService(
|
||||
$this->userRepository,
|
||||
$this->sessionManager,
|
||||
$this->loginAttemptRepository,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── authenticate ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner l'utilisateur si les identifiants sont corrects.
|
||||
*/
|
||||
public function testAuthenticateValidCredentials(): void
|
||||
{
|
||||
$password = 'motdepasse1';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password);
|
||||
|
||||
$this->userRepository->method('findByUsername')->with('alice')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('alice', $password);
|
||||
|
||||
$this->assertSame($user, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit normaliser le nom d'utilisateur en minuscules.
|
||||
*/
|
||||
public function testAuthenticateNormalizesUsername(): void
|
||||
{
|
||||
$password = 'motdepasse1';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password);
|
||||
|
||||
$this->userRepository->method('findByUsername')->with('alice')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('ALICE', $password);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner null si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testAuthenticateUnknownUser(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
|
||||
$result = $this->service->authenticate('inconnu', 'motdepasse1');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner null si le mot de passe est incorrect.
|
||||
*/
|
||||
public function testAuthenticateWrongPassword(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse');
|
||||
$this->userRepository->method('findByUsername')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('alice', 'mauvaismdp');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
|
||||
// ── changePassword ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* changePassword() doit mettre à jour le hash si les données sont valides.
|
||||
*/
|
||||
public function testChangePasswordWithValidData(): void
|
||||
{
|
||||
$password = 'ancienmdp1';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
|
||||
$this->userRepository->method('findById')->with(1)->willReturn($user);
|
||||
$this->userRepository->expects($this->once())->method('updatePassword')->with(1);
|
||||
|
||||
$this->service->changePassword(1, $password, 'nouveaumdp1');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever une exception si le mot de passe actuel est incorrect.
|
||||
*/
|
||||
public function testChangePasswordWrongCurrentPassword(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse', 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/actuel incorrect/');
|
||||
|
||||
$this->service->changePassword(1, 'mauvaismdp', 'nouveaumdp1');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() avec exactement 8 caractères doit réussir (frontière basse).
|
||||
*/
|
||||
public function testChangePasswordMinimumLengthNewPassword(): void
|
||||
{
|
||||
$password = 'ancienmdp1';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
$this->userRepository->expects($this->once())->method('updatePassword');
|
||||
|
||||
// Ne doit pas lever d'exception
|
||||
$this->service->changePassword(1, $password, '12345678');
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever WeakPasswordException si le nouveau mot de passe est trop court.
|
||||
*/
|
||||
public function testChangePasswordTooShortNewPasswordThrowsWeakPasswordException(): void
|
||||
{
|
||||
$password = 'ancienmdp1';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
|
||||
$this->expectException(WeakPasswordException::class);
|
||||
|
||||
$this->service->changePassword(1, $password, '1234567');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever NotFoundException si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testChangePasswordUnknownUserThrowsNotFoundException(): void
|
||||
{
|
||||
$this->userRepository->method('findById')->willReturn(null);
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->service->changePassword(99, 'ancienmdp1', 'nouveaumdp1');
|
||||
}
|
||||
|
||||
|
||||
// ── login / logout / isLoggedIn ────────────────────────────────
|
||||
|
||||
/**
|
||||
* login() doit appeler SessionManager::setUser() avec les bonnes données.
|
||||
*/
|
||||
public function testLoginCallsSetUser(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'secret', 7, User::ROLE_ADMIN);
|
||||
|
||||
$this->sessionManager->expects($this->once())
|
||||
->method('setUser')
|
||||
->with(7, 'alice', User::ROLE_ADMIN);
|
||||
|
||||
$this->service->login($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* logout() doit appeler SessionManager::destroy().
|
||||
*/
|
||||
public function testLogoutCallsDestroy(): void
|
||||
{
|
||||
$this->sessionManager->expects($this->once())->method('destroy');
|
||||
|
||||
$this->service->logout();
|
||||
}
|
||||
|
||||
/**
|
||||
* isLoggedIn() doit déléguer à SessionManager::isAuthenticated().
|
||||
*/
|
||||
public function testIsLoggedInDelegatesToSessionManager(): void
|
||||
{
|
||||
$this->sessionManager->method('isAuthenticated')->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->isLoggedIn());
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
|
||||
*
|
||||
* @param string $username Nom d'utilisateur
|
||||
* @param string $email Adresse e-mail
|
||||
* @param string $password Mot de passe en clair (haché en bcrypt)
|
||||
* @param int $id Identifiant (défaut : 1)
|
||||
* @param string $role Rôle (défaut : 'user')
|
||||
*/
|
||||
private function makeUser(
|
||||
string $username,
|
||||
string $email,
|
||||
string $password = 'motdepasse1',
|
||||
int $id = 1,
|
||||
string $role = User::ROLE_USER,
|
||||
): User {
|
||||
return new User(
|
||||
$id,
|
||||
$username,
|
||||
$email,
|
||||
password_hash($password, PASSWORD_BCRYPT),
|
||||
$role,
|
||||
);
|
||||
}
|
||||
}
|
||||
276
tests/Auth/LoginAttemptRepositoryTest.php
Normal file
276
tests/Auth/LoginAttemptRepositoryTest.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\LoginAttemptRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour LoginAttemptRepository.
|
||||
*
|
||||
* Vérifie la logique interne de gestion des tentatives de connexion :
|
||||
* lecture par IP, UPSERT atomique via prepare/execute, réinitialisation
|
||||
* et nettoyage des entrées expirées.
|
||||
*
|
||||
* recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE)
|
||||
* pour éliminer la race condition du pattern SELECT + INSERT/UPDATE.
|
||||
* Les tests vérifient que prepare() est appelé avec le bon SQL et que
|
||||
* execute() reçoit les bons paramètres.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
final class LoginAttemptRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private LoginAttemptRepository $repository;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO et le dépôt avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new LoginAttemptRepository($this->db);
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────
|
||||
|
||||
private function stmtOk(): PDOStatement&MockObject
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findByIp ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner null si aucune entrée n'existe pour cette IP.
|
||||
*/
|
||||
public function testFindByIpReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner le tableau associatif si une entrée existe.
|
||||
*/
|
||||
public function testFindByIpReturnsRowWhenFound(): void
|
||||
{
|
||||
$row = [
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 3,
|
||||
'locked_until' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame($row, $this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() exécute avec la bonne IP.
|
||||
*/
|
||||
public function testFindByIpQueriesWithCorrectIp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':ip' => '10.0.0.1']);
|
||||
|
||||
$this->repository->findByIp('10.0.0.1');
|
||||
}
|
||||
|
||||
|
||||
// ── recordFailure — UPSERT atomique ────────────────────────────
|
||||
|
||||
/**
|
||||
* recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE)
|
||||
* via prepare/execute — garantie de l'atomicité.
|
||||
*/
|
||||
public function testRecordFailureUsesUpsertSql(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('INSERT INTO login_attempts'),
|
||||
$this->stringContains('ON CONFLICT'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer la bonne IP au paramètre :ip.
|
||||
*/
|
||||
public function testRecordFailurePassesCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool => $p[':ip'] === $ip));
|
||||
|
||||
$this->repository->recordFailure($ip, 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2.
|
||||
*
|
||||
* PDO interdit les paramètres nommés dupliqués dans une même requête ;
|
||||
* le seuil est donc passé deux fois avec des noms distincts.
|
||||
*/
|
||||
public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void
|
||||
{
|
||||
$max = 7;
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool =>
|
||||
$p[':max1'] === $max && $p[':max2'] === $max
|
||||
));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', $max, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit calculer locked_until dans la fenêtre attendue.
|
||||
*
|
||||
* :lock1 et :lock2 doivent être égaux et correspondre à
|
||||
* maintenant + lockMinutes (± 5 secondes de tolérance d'exécution).
|
||||
*/
|
||||
public function testRecordFailureLockedUntilIsInCorrectWindow(): void
|
||||
{
|
||||
$lockMinutes = 15;
|
||||
$before = time() + $lockMinutes * 60 - 5;
|
||||
$after = time() + $lockMinutes * 60 + 5;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':lock1', ':lock2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':lock1'] === $p[':lock2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, $lockMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent.
|
||||
*/
|
||||
public function testRecordFailureNowParamsAreCurrentTime(): void
|
||||
{
|
||||
$before = time() - 2;
|
||||
$after = time() + 2;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':now1', ':now2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':now1'] === $p[':now2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
|
||||
// ── resetForIp ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* resetForIp() doit préparer un DELETE ciblant la bonne IP.
|
||||
*/
|
||||
public function testResetForIpCallsDeleteWithCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM login_attempts'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':ip' => $ip]);
|
||||
|
||||
$this->repository->resetForIp($ip);
|
||||
}
|
||||
|
||||
|
||||
// ── deleteExpired ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* deleteExpired() doit préparer un DELETE ciblant locked_until expiré
|
||||
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
|
||||
*/
|
||||
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM login_attempts'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $params): bool {
|
||||
return isset($params[':now'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $params[':now']);
|
||||
}));
|
||||
|
||||
$this->repository->deleteExpired();
|
||||
}
|
||||
}
|
||||
109
tests/Auth/MiddlewareTest.php
Normal file
109
tests/Auth/MiddlewareTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\Middleware\AdminMiddleware;
|
||||
use App\Auth\Middleware\AuthMiddleware;
|
||||
use App\Auth\Middleware\EditorMiddleware;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
final class MiddlewareTest extends TestCase
|
||||
{
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private ServerRequestInterface $request;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->request = (new ServerRequestFactory())->createServerRequest('GET', '/admin');
|
||||
}
|
||||
|
||||
public function testAuthMiddlewareRedirectsGuests(): void
|
||||
{
|
||||
$this->sessionManager->method('isAuthenticated')->willReturn(false);
|
||||
|
||||
$middleware = new AuthMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/auth/login', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testAuthMiddlewareDelegatesWhenAuthenticated(): void
|
||||
{
|
||||
$this->sessionManager->method('isAuthenticated')->willReturn(true);
|
||||
|
||||
$middleware = new AuthMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAdminMiddlewareRedirectsNonAdmins(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
|
||||
$middleware = new AdminMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/admin/posts', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testAdminMiddlewareDelegatesForAdmins(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
|
||||
$middleware = new AdminMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testEditorMiddlewareRedirectsWhenNeitherAdminNorEditor(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$middleware = new EditorMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/admin/posts', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testEditorMiddlewareDelegatesForEditors(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(true);
|
||||
|
||||
$middleware = new EditorMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
private function makeHandler(int $status = 200): RequestHandlerInterface
|
||||
{
|
||||
return new class ($status) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly int $status)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return new Response($this->status);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
409
tests/Auth/PasswordResetControllerTest.php
Normal file
409
tests/Auth/PasswordResetControllerTest.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?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)
|
||||
*/
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
249
tests/Auth/PasswordResetRepositoryTest.php
Normal file
249
tests/Auth/PasswordResetRepositoryTest.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\PasswordResetRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PasswordResetRepository.
|
||||
*
|
||||
* Vérifie que chaque méthode construit le bon SQL avec les bons paramètres,
|
||||
* notamment la logique de non-suppression des tokens (used_at) et la
|
||||
* condition AND used_at IS NULL pour les tokens actifs.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
final class PasswordResetRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PasswordResetRepository $repository;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO et le dépôt avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PasswordResetRepository($this->db);
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────
|
||||
|
||||
private function stmtOk(array|false $fetchReturn = false): PDOStatement&MockObject
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn($fetchReturn);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit préparer un INSERT sur 'password_resets'
|
||||
* avec le user_id, le token_hash et la date d'expiration fournis.
|
||||
*/
|
||||
public function testCreateCallsInsertWithCorrectData(): void
|
||||
{
|
||||
$userId = 1;
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 3600);
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->method('prepare')
|
||||
->with($this->stringContains('INSERT INTO password_resets'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($userId, $tokenHash, $expiresAt): bool {
|
||||
return $data[':user_id'] === $userId
|
||||
&& $data[':token_hash'] === $tokenHash
|
||||
&& $data[':expires_at'] === $expiresAt
|
||||
&& isset($data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->repository->create($userId, $tokenHash, $expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit renseigner :created_at au format 'Y-m-d H:i:s'.
|
||||
*/
|
||||
public function testCreateSetsCreatedAt(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return isset($data[':created_at'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->repository->create(1, 'hash', date('Y-m-d H:i:s', time() + 3600));
|
||||
}
|
||||
|
||||
|
||||
// ── findActiveByHash ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit retourner null si aucun token actif ne correspond au hash.
|
||||
*/
|
||||
public function testFindActiveByHashReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtOk(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findActiveByHash('hashquinaexistepas'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit retourner la ligne si un token actif correspond au hash.
|
||||
*/
|
||||
public function testFindActiveByHashReturnsRowWhenFound(): void
|
||||
{
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$row = [
|
||||
'id' => 1,
|
||||
'user_id' => 42,
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$stmt = $this->stmtOk($row);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame($row, $this->repository->findActiveByHash($tokenHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit inclure AND used_at IS NULL dans le SQL
|
||||
* pour n'obtenir que les tokens non consommés.
|
||||
*/
|
||||
public function testFindActiveByHashFiltersOnNullUsedAt(): void
|
||||
{
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$stmt = $this->stmtOk(false);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('used_at IS NULL'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':token_hash' => $tokenHash]);
|
||||
|
||||
$this->repository->findActiveByHash($tokenHash);
|
||||
}
|
||||
|
||||
|
||||
// ── invalidateByUserId ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* invalidateByUserId() doit préparer un UPDATE renseignant :used_at
|
||||
* pour tous les tokens non consommés de l'utilisateur.
|
||||
*/
|
||||
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
|
||||
{
|
||||
$userId = 42;
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->method('prepare')
|
||||
->with($this->stringContains('UPDATE password_resets'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($userId): bool {
|
||||
return $data[':user_id'] === $userId
|
||||
&& isset($data[':used_at'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':used_at']);
|
||||
}));
|
||||
|
||||
$this->repository->invalidateByUserId($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL
|
||||
* pour ne cibler que les tokens encore actifs.
|
||||
*/
|
||||
public function testInvalidateByUserIdFiltersOnActiveTokens(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('used_at IS NULL'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->invalidateByUserId(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* invalidateByUserId() ne doit pas utiliser DELETE — les tokens
|
||||
* sont invalidés via used_at pour conserver la traçabilité.
|
||||
*/
|
||||
public function testInvalidateByUserIdNeverCallsDelete(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')
|
||||
->with($this->stringContains('UPDATE'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->db->expects($this->never())->method('exec');
|
||||
|
||||
$this->repository->invalidateByUserId(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── consumeActiveToken ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer
|
||||
* et retourner le token en une seule opération atomique.
|
||||
*/
|
||||
public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void
|
||||
{
|
||||
$row = ['id' => 1, 'user_id' => 42, 'token_hash' => 'hash', 'used_at' => null];
|
||||
$stmt = $this->stmtOk($row);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->callback(fn (string $sql): bool =>
|
||||
str_contains($sql, 'UPDATE password_resets')
|
||||
&& str_contains($sql, 'used_at IS NULL')
|
||||
&& str_contains($sql, 'RETURNING *')
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->consumeActiveToken('hash', '2024-01-01 00:00:00');
|
||||
|
||||
$this->assertSame($row, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* consumeActiveToken() retourne null si aucun token actif ne correspond.
|
||||
*/
|
||||
public function testConsumeActiveTokenReturnsNullWhenNoRowMatches(): void
|
||||
{
|
||||
$stmt = $this->stmtOk(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->consumeActiveToken('missing', '2024-01-01 00:00:00'));
|
||||
}
|
||||
}
|
||||
63
tests/Auth/PasswordResetServiceIntegrationTest.php
Normal file
63
tests/Auth/PasswordResetServiceIntegrationTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
use App\Auth\PasswordResetRepository;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Shared\Database\Migrator;
|
||||
use App\Shared\Mail\MailServiceInterface;
|
||||
use App\User\User;
|
||||
use App\User\UserRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PasswordResetServiceIntegrationTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
private PasswordResetService $service;
|
||||
private UserRepository $users;
|
||||
private PasswordResetRepository $resets;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||
Migrator::run($this->db);
|
||||
|
||||
$this->users = new UserRepository($this->db);
|
||||
$this->resets = new PasswordResetRepository($this->db);
|
||||
$mail = new class implements MailServiceInterface {
|
||||
public function send(string $to, string $subject, string $template, array $context = []): void
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$this->service = new PasswordResetService($this->resets, $this->users, $mail, $this->db);
|
||||
}
|
||||
|
||||
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void
|
||||
{
|
||||
$userId = $this->users->create(new User(0, 'alice', 'alice@example.com', password_hash('ancienpass1', PASSWORD_BCRYPT)));
|
||||
$tokenRaw = 'token-brut-integration';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
$this->resets->create($userId, $tokenHash, date('Y-m-d H:i:s', time() + 3600));
|
||||
|
||||
$this->service->resetPassword($tokenRaw, 'nouveaupass1');
|
||||
|
||||
$user = $this->users->findById($userId);
|
||||
self::assertNotNull($user);
|
||||
self::assertTrue(password_verify('nouveaupass1', $user->getPasswordHash()));
|
||||
|
||||
$row = $this->db->query("SELECT used_at FROM password_resets WHERE token_hash = '{$tokenHash}'")->fetch();
|
||||
self::assertIsArray($row);
|
||||
self::assertNotEmpty($row['used_at']);
|
||||
|
||||
$this->expectException(InvalidResetTokenException::class);
|
||||
$this->service->resetPassword($tokenRaw, 'encoreplusfort1');
|
||||
}
|
||||
}
|
||||
316
tests/Auth/PasswordResetServiceTest.php
Normal file
316
tests/Auth/PasswordResetServiceTest.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
use App\Auth\PasswordResetRepositoryInterface;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Shared\Mail\MailServiceInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use App\User\User;
|
||||
use App\User\UserRepositoryInterface;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PasswordResetService.
|
||||
*
|
||||
* Vérifie la génération de token, la validation et la réinitialisation
|
||||
* du mot de passe. Les dépendances sont mockées via leurs interfaces.
|
||||
*/
|
||||
final class PasswordResetServiceTest extends TestCase
|
||||
{
|
||||
/** @var PasswordResetRepositoryInterface&MockObject */
|
||||
private PasswordResetRepositoryInterface $resetRepository;
|
||||
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var MailServiceInterface&MockObject */
|
||||
private MailServiceInterface $mailService;
|
||||
|
||||
private PasswordResetService $service;
|
||||
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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->isType('string'), $this->isType('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->isType('string'),
|
||||
'emails/password-reset.twig',
|
||||
$this->isType('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->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->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->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->method('findActiveByHash')->willReturn($row);
|
||||
$this->userRepository->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->isType('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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user