first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View 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());
}
}

View 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, '/');
}
}

View 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');
}
}

View 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,
);
}
}

View 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();
}
}

View 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);
}
};
}
}

View 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'),
);
}
}

View 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'));
}
}

View 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');
}
}

View 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),
);
}
}