first commit

This commit is contained in:
julien
2026-03-20 22:13:41 +01:00
commit 41f8b3afb4
323 changed files with 27222 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\UI\Http\AccountController;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;
/**
* 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.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AccountControllerTest extends ControllerTestBase
{
/** @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(), '@Identity/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('12 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,95 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\Command\AdminDeleteUserCommand;
use Netig\Netslim\Identity\Application\UseCase\AdminDeleteUser;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\CannotDeleteOwnAccountException;
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorDeletionException;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AdminDeleteUserTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private AdminDeleteUser $useCase;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->useCase = new AdminDeleteUser($this->userRepository);
}
public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void
{
$this->userRepository->expects($this->once())
->method('findById')
->with(42)
->willReturn(null);
$this->userRepository->expects($this->never())->method('delete');
$this->expectException(NotFoundException::class);
$this->useCase->handle(new AdminDeleteUserCommand(1, 42));
}
public function testHandleThrowsWhenTargetUserIsAdmin(): void
{
$admin = $this->makeUser(7, 'root', User::ROLE_ADMIN);
$this->userRepository->expects($this->once())
->method('findById')
->with(7)
->willReturn($admin);
$this->userRepository->expects($this->never())->method('delete');
$this->expectException(ProtectedAdministratorDeletionException::class);
$this->useCase->handle(new AdminDeleteUserCommand(1, 7));
}
public function testHandleThrowsWhenActorTriesToDeleteOwnAccount(): void
{
$user = $this->makeUser(5, 'alice', User::ROLE_EDITOR);
$this->userRepository->expects($this->once())
->method('findById')
->with(5)
->willReturn($user);
$this->userRepository->expects($this->never())->method('delete');
$this->expectException(CannotDeleteOwnAccountException::class);
$this->useCase->handle(new AdminDeleteUserCommand(5, 5));
}
public function testHandleDeletesUserAndReturnsTargetUserOnSuccess(): void
{
$user = $this->makeUser(9, 'charlie', User::ROLE_USER);
$this->userRepository->expects($this->once())
->method('findById')
->with(9)
->willReturn($user);
$this->userRepository->expects($this->once())
->method('delete')
->with(9);
$result = $this->useCase->handle(new AdminDeleteUserCommand(1, 9));
$this->assertSame($user, $result);
}
private function makeUser(int $id, string $username, string $role): User
{
return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\UI\Http\AdminHomePath;
use PHPUnit\Framework\TestCase;
final class AdminHomePathTest extends TestCase
{
private ?string $previous = null;
protected function setUp(): void
{
$this->previous = $_ENV['ADMIN_HOME_PATH'] ?? null;
unset($_ENV['ADMIN_HOME_PATH']);
}
protected function tearDown(): void
{
if ($this->previous === null) {
unset($_ENV['ADMIN_HOME_PATH']);
} else {
$_ENV['ADMIN_HOME_PATH'] = $this->previous;
}
}
public function testResolveDefaultsToGenericAdminPath(): void
{
self::assertSame('/admin', AdminHomePath::resolve());
}
public function testResolveNormalizesConfiguredPath(): void
{
$_ENV['ADMIN_HOME_PATH'] = 'admin/posts';
self::assertSame('/admin/posts', AdminHomePath::resolve());
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\Command\AdminUpdateUserRoleCommand;
use Netig\Netslim\Identity\Application\UseCase\AdminUpdateUserRole;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\CannotModifyOwnRoleException;
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorRoleChangeException;
use Netig\Netslim\Identity\Domain\Exception\RoleAssignmentNotAllowedException;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AdminUpdateUserRoleTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private AdminUpdateUserRole $useCase;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->useCase = new AdminUpdateUserRole($this->userRepository, new RolePolicy());
}
public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void
{
$this->userRepository->expects($this->once())
->method('findById')
->with(42)
->willReturn(null);
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(NotFoundException::class);
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 42, User::ROLE_EDITOR));
}
public function testHandleThrowsWhenActorTriesToModifyOwnRole(): void
{
$user = $this->makeUser(5, 'alice', User::ROLE_USER);
$this->userRepository->expects($this->once())
->method('findById')
->with(5)
->willReturn($user);
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(CannotModifyOwnRoleException::class);
$this->useCase->handle(new AdminUpdateUserRoleCommand(5, 5, User::ROLE_EDITOR));
}
public function testHandleThrowsWhenTargetUserIsAdmin(): void
{
$admin = $this->makeUser(7, 'root', User::ROLE_ADMIN);
$this->userRepository->expects($this->once())
->method('findById')
->with(7)
->willReturn($admin);
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(ProtectedAdministratorRoleChangeException::class);
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 7, User::ROLE_EDITOR));
}
public function testHandleThrowsWhenRequestedRoleIsNotAssignable(): void
{
$user = $this->makeUser(8, 'bob', User::ROLE_USER);
$this->userRepository->expects($this->once())
->method('findById')
->with(8)
->willReturn($user);
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(RoleAssignmentNotAllowedException::class);
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 8, User::ROLE_ADMIN));
}
public function testHandleUpdatesRoleAndReturnsTargetUserOnSuccess(): void
{
$user = $this->makeUser(9, 'charlie', User::ROLE_USER);
$this->userRepository->expects($this->once())
->method('findById')
->with(9)
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updateRole')
->with(9, User::ROLE_EDITOR);
$result = $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 9, User::ROLE_EDITOR));
$this->assertSame($user, $result);
}
private function makeUser(int $id, string $username, string $role): User
{
return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role);
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Infrastructure\AdminUserProvisioner;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour AdminUserProvisioner.
*
* Vérifie que le provisionnement insère le compte administrateur quand il est absent,
* et ne fait rien si le compte existe déjà (idempotence).
*
* PDO et PDOStatement sont mockés pour isoler le provisionneur identité de la base de données.
* Les variables d'environnement sont définies dans setUp() et restaurées dans tearDown().
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AdminUserProvisionerTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
/** @var array<string, string> Variables d'environnement sauvegardées avant chaque test */
private array $envBackup;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
];
$_ENV['ADMIN_USERNAME'] = 'admin';
$_ENV['ADMIN_EMAIL'] = 'admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'secret123456';
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
$_ENV[$key] = $value;
}
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un PDOStatement mock retournant $fetchColumnValue pour fetchColumn().
*/
private function stmtReturning(mixed $fetchColumnValue): MockObject&PDOStatement
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn($fetchColumnValue);
return $stmt;
}
private function stmtForWrite(): MockObject&PDOStatement
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── seed() — admin absent ──────────────────────────────────────
/**
* seed() doit insérer le compte admin quand aucun utilisateur
* portant ce nom d'utilisateur n'existe en base.
*/
public function testSeedInsertsAdminWhenAbsent(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->expects($this->exactly(2))
->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com'
&& $data[':role'] === 'admin'
&& isset($data[':password_hash'], $data[':created_at'])
&& password_verify('secret123456', $data[':password_hash']);
}));
AdminUserProvisioner::seed($this->db);
}
/**
* seed() doit normaliser le nom d'utilisateur en minuscules
* et supprimer les espaces autour.
*/
public function testSeedNormalizesUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' ADMIN ';
$_ENV['ADMIN_EMAIL'] = ' ADMIN@EXAMPLE.COM ';
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com';
}));
AdminUserProvisioner::seed($this->db);
}
/**
* seed() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testSeedHashesPasswordBeforeInsert(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
// Le hash ne doit pas être le mot de passe brut
return $data[':password_hash'] !== 'secret123456'
// Et doit être vérifiable avec password_verify
&& password_verify('secret123456', $data[':password_hash']);
}));
AdminUserProvisioner::seed($this->db);
}
/**
* seed() doit renseigner created_at au format 'Y-m-d H:i:s'.
*/
public function testSeedSetsCreatedAt(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->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']);
}));
AdminUserProvisioner::seed($this->db);
}
// ── seed() — admin présent (idempotence) ───────────────────────
/**
* seed() ne doit pas exécuter d'INSERT si le compte admin existe déjà.
*/
public function testSeedDoesNotInsertWhenAdminExists(): void
{
// fetchColumn() retourne l'id existant — le compte est déjà là
$selectStmt = $this->stmtReturning('1');
$this->db->expects($this->once())
->method('prepare')
->willReturn($selectStmt);
// prepare() ne doit être appelé qu'une fois (SELECT uniquement, pas d'INSERT)
AdminUserProvisioner::seed($this->db);
}
/**
* seed() vérifie l'existence du compte via le nom d'utilisateur normalisé.
*/
public function testSeedChecksExistenceByNormalizedUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' Admin ';
$selectStmt = $this->stmtReturning('1');
$this->db->method('prepare')->willReturn($selectStmt);
$selectStmt->expects($this->once())
->method('execute')
->with([':username' => 'admin']);
AdminUserProvisioner::seed($this->db);
}
public function testSeedRejectsTooShortAdminPassword(): void
{
$_ENV['ADMIN_PASSWORD'] = 'short123';
$this->db->expects($this->never())
->method('prepare');
$this->expectException(\Netig\Netslim\Identity\Domain\Exception\WeakPasswordException::class);
$this->expectExceptionMessage('au moins 12 caractères');
AdminUserProvisioner::seed($this->db);
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\UI\Http\AuthController;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;
/**
* 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.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AuthControllerTest extends ControllerTestBase
{
/** @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 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');
}
/**
* 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(), '@Identity/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 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');
}
/**
* 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,228 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthApplicationService;
use Netig\Netslim\Identity\Application\AuthSessionInterface;
use Netig\Netslim\Identity\Application\UseCase\AuthenticateUser;
use Netig\Netslim\Identity\Application\UseCase\ChangePassword;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour la protection anti-brute force de AuthApplicationService.
*
* Vérifie le comportement de checkRateLimit(), recordFailure() et
* resetRateLimit(). Les constantes testées correspondent aux valeurs
* définies dans AuthApplicationService :
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AuthServiceRateLimitTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var AuthSessionInterface&MockObject */
private AuthSessionInterface $authSession;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthApplicationService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->authSession = $this->createMock(AuthSessionInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthApplicationService(
$this->authSession,
$this->loginAttemptRepository,
new LoginRateLimitPolicy(),
new AuthenticateUser($this->userRepository, new PasswordPolicy()),
new ChangePassword($this->userRepository, new PasswordPolicy()),
);
}
// ── 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,286 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthApplicationService;
use Netig\Netslim\Identity\Application\AuthSessionInterface;
use Netig\Netslim\Identity\Application\UseCase\AuthenticateUser;
use Netig\Netslim\Identity\Application\UseCase\ChangePassword;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour AuthApplicationService.
*
* 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.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AuthServiceTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var AuthSessionInterface&MockObject */
private AuthSessionInterface $authSession;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthApplicationService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->authSession = $this->createMock(AuthSessionInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthApplicationService(
$this->authSession,
$this->loginAttemptRepository,
new LoginRateLimitPolicy(),
new AuthenticateUser($this->userRepository, new PasswordPolicy()),
new ChangePassword($this->userRepository, new PasswordPolicy()),
);
}
// ── authenticate ───────────────────────────────────────────────
/**
* authenticate() doit retourner l'utilisateur si les identifiants sont corrects.
*/
public function testAuthenticateValidCredentials(): void
{
$password = 'motdepasse12';
$user = $this->makeUser('alice', 'alice@example.com', $password);
$this->userRepository->expects($this->once())->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 = 'motdepasse12';
$user = $this->makeUser('alice', 'alice@example.com', $password);
$this->userRepository->expects($this->once())->method('findByUsername')->with('alice')->willReturn($user);
$result = $this->service->authenticate('ALICE', $password);
$this->assertNotNull($result);
}
/**
* authenticate() ne doit pas supprimer les espaces du mot de passe saisi.
*/
public function testAuthenticatePreservesPasswordWhitespace(): void
{
$password = ' motdepasse12 ';
$user = $this->makeUser('alice', 'alice@example.com', $password);
$this->userRepository->expects($this->exactly(2))->method('findByUsername')->with('alice')->willReturn($user);
$this->assertSame($user, $this->service->authenticate('alice', $password));
$this->assertNull($this->service->authenticate('alice', 'motdepasse12'));
}
/**
* authenticate() doit retourner null si l'utilisateur est introuvable.
*/
public function testAuthenticateUnknownUser(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$result = $this->service->authenticate('inconnu', 'motdepasse12');
$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 = 'ancienmdp12';
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
$this->userRepository->expects($this->once())->method('updatePassword')->with(1);
$this->service->changePassword(1, $password, 'nouveaumdp12');
}
/**
* 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', 'nouveaumdp12');
}
/**
* changePassword() doit conserver exactement les espaces du nouveau mot de passe.
*/
public function testChangePasswordPreservesNewPasswordWhitespace(): void
{
$currentPassword = 'ancienmdp12';
$newPassword = ' nouveaumdp12 ';
$user = $this->makeUser('alice', 'alice@example.com', $currentPassword, 1);
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with(1, $this->callback(static function (string $hash) use ($newPassword): bool {
return password_verify($newPassword, $hash)
&& !password_verify(trim($newPassword), $hash);
}));
$this->service->changePassword(1, $currentPassword, $newPassword);
}
/**
* changePassword() avec exactement 12 caractères doit réussir (frontière basse).
*/
public function testChangePasswordMinimumLengthNewPassword(): void
{
$password = 'ancienmdp12';
$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, '123456789012');
$this->addToAssertionCount(1);
}
/**
* changePassword() doit lever WeakPasswordException si le nouveau mot de passe est trop court.
*/
public function testChangePasswordTooShortNewPasswordThrowsWeakPasswordException(): void
{
$password = 'ancienmdp12';
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
$this->userRepository->method('findById')->willReturn($user);
$this->expectException(WeakPasswordException::class);
$this->service->changePassword(1, $password, '12345678901');
}
/**
* 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, 'ancienmdp12', 'nouveaumdp12');
}
// ── 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->authSession->expects($this->once())
->method('startForUser')
->with($user);
$this->service->login($user);
}
/**
* logout() doit appeler SessionManager::destroy().
*/
public function testLogoutCallsDestroy(): void
{
$this->authSession->expects($this->once())->method('clear');
$this->service->logout();
}
/**
* isLoggedIn() doit déléguer à SessionManager::isAuthenticated().
*/
public function testIsLoggedInDelegatesToSessionManager(): void
{
$this->authSession->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 = 'motdepasse12',
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,36 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthorizationApplicationService;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Policy\Permission;
use Netig\Netslim\Identity\Domain\Policy\RolePermissionMatrix;
use PHPUnit\Framework\TestCase;
final class AuthorizationServiceTest extends TestCase
{
private AuthorizationApplicationService $service;
protected function setUp(): void
{
$this->service = new AuthorizationApplicationService(new RolePermissionMatrix());
}
public function testEditorGetsFineGrainedPermissions(): void
{
$editor = new User(1, 'editor', 'editor@example.test', 'hash', User::ROLE_EDITOR);
self::assertTrue($this->service->canUser($editor, Permission::CONTENT_PUBLISH));
self::assertTrue($this->service->canUser($editor, Permission::MEDIA_MANAGE));
self::assertFalse($this->service->canUser($editor, Permission::USERS_MANAGE));
}
public function testAdminHasWildcardPermissions(): void
{
self::assertTrue($this->service->canRole(User::ROLE_ADMIN, Permission::SETTINGS_MANAGE));
self::assertContains('*', $this->service->permissionsForRole(User::ROLE_ADMIN));
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Infrastructure\PdoLoginAttemptRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PdoLoginAttemptRepository.
*
* Vérifie la logique de gestion des tentatives de connexion :
* lecture par IP, enregistrement d'un échec, réinitialisation ciblée
* et nettoyage des entrées expirées.
*
* Les assertions privilégient l'intention métier (opération, table,
* paramètres liés, horodatage cohérent) plutôt que la forme SQL exacte,
* afin de laisser un peu plus de liberté d'évolution interne.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class LoginAttemptRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PdoLoginAttemptRepository $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 PdoLoginAttemptRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
private function stmtOk(): MockObject&PDOStatement
{
$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 = [
'scope' => 'login_ip',
'rate_key' => '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([':scope' => 'login_ip', ':rate_key' => '10.0.0.1']);
$this->repository->findByIp('10.0.0.1');
}
// ── recordFailure — UPSERT atomique ────────────────────────────
/**
* recordFailure() doit préparer une écriture sur rate_limits
* puis exécuter l'opération avec les bons paramètres métier.
*/
public function testRecordFailureUsesUpsertSql(): void
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('rate_limits'),
$this->stringContains('attempts'),
$this->stringContains('locked_until'),
))
->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[':scope1'] === 'login_ip' && $p[':rate_key1'] === $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 une suppression 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->logicalAnd(
$this->stringContains('rate_limits'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':scope' => 'login_ip', ':rate_key' => $ip]);
$this->repository->resetForIp($ip);
}
// ── deleteExpired ──────────────────────────────────────────────
/**
* deleteExpired() doit préparer une suppression sur rate_limits
* 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->logicalAnd(
$this->stringContains('rate_limits'),
$this->stringContains('DELETE'),
))
->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,110 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\UI\Http\Middleware\AdminMiddleware;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Netig\Netslim\Identity\UI\Http\Middleware\EditorMiddleware;
use Netig\Netslim\Kernel\Http\Application\Session\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;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
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', $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', $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,85 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\UI\Http\Request\ChangePasswordRequest;
use Netig\Netslim\Identity\UI\Http\Request\CreateUserRequest;
use Netig\Netslim\Identity\UI\Http\Request\LoginRequest;
use Netig\Netslim\Identity\UI\Http\Request\ResetPasswordRequest;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class PasswordRequestHandlingTest extends TestCase
{
public function testLoginRequestPreservesPasswordWhitespace(): void
{
$request = (new ServerRequestFactory())
->createServerRequest('POST', '/login')
->withParsedBody([
'username' => ' alice ',
'password' => ' secret ',
]);
$payload = LoginRequest::fromRequest($request);
self::assertSame('alice', $payload->username);
self::assertSame(' secret ', $payload->password);
}
public function testChangePasswordRequestPreservesPasswordWhitespace(): void
{
$request = (new ServerRequestFactory())
->createServerRequest('POST', '/account/password')
->withParsedBody([
'current_password' => ' current ',
'new_password' => ' nextpass ',
'new_password_confirm' => ' nextpass ',
]);
$payload = ChangePasswordRequest::fromRequest($request);
self::assertSame(' current ', $payload->currentPassword);
self::assertSame(' nextpass ', $payload->newPassword);
self::assertSame(' nextpass ', $payload->newPasswordConfirm);
}
public function testResetPasswordRequestPreservesPasswordWhitespace(): void
{
$request = (new ServerRequestFactory())
->createServerRequest('POST', '/password/reset')
->withParsedBody([
'token' => ' token ',
'new_password' => ' nextpass ',
'new_password_confirm' => ' nextpass ',
]);
$payload = ResetPasswordRequest::fromRequest($request);
self::assertSame('token', $payload->token);
self::assertSame(' nextpass ', $payload->newPassword);
self::assertSame(' nextpass ', $payload->newPasswordConfirm);
}
public function testCreateUserRequestPreservesPasswordWhitespace(): void
{
$request = (new ServerRequestFactory())
->createServerRequest('POST', '/admin/users')
->withParsedBody([
'username' => ' Alice ',
'email' => ' alice@example.com ',
'password' => ' secret123 ',
'password_confirm' => ' secret123 ',
'role' => 'editor',
]);
$payload = CreateUserRequest::fromRequest($request, ['user', 'editor']);
self::assertSame('Alice', $payload->username);
self::assertSame('alice@example.com', $payload->email);
self::assertSame(' secret123 ', $payload->password);
self::assertSame(' secret123 ', $payload->passwordConfirm);
self::assertSame('editor', $payload->role);
}
}

View File

@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\UI\Http\PasswordResetController;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;
/**
* Tests unitaires pour PasswordResetController.
*
* Couvre le flux en deux étapes :
* 1. showForgot() / forgot() — demande de réinitialisation
* 2. showReset() / reset() — saisie du nouveau mot de passe
*
* Points clés :
* - forgot() vérifie le rate limit par IP avant tout traitement
* - forgot() enregistre systématiquement une tentative (anti-canal-caché)
* - forgot() affiche toujours un message de succès générique (anti-énumération)
* - showReset() valide le token avant d'afficher le formulaire
* - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetControllerTest extends ControllerTestBase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var PasswordResetServiceInterface&MockObject */
private PasswordResetServiceInterface $passwordResetService;
/** @var AuthServiceInterface&MockObject */
private AuthServiceInterface $authService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private ClientIpResolver $clientIpResolver;
private PasswordResetController $controller;
private const BASE_URL = 'https://example.com';
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
$this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->clientIpResolver = new ClientIpResolver(['*']);
// Par défaut : IP non verrouillée
$this->authService->method('checkPasswordResetRateLimit')->willReturn(0);
$this->controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$this->authService,
$this->flash,
$this->clientIpResolver,
self::BASE_URL,
);
}
// ── showForgot ───────────────────────────────────────────────────
/**
* showForgot() doit rendre le formulaire de demande de réinitialisation.
*/
public function testShowForgotRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), '@Identity/password-forgot.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showForgot($this->makeGet('/password/forgot'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── forgot — rate limiting ────────────────────────────────────────
/**
* forgot() doit rediriger avec une erreur générique si l'IP est verrouillée.
*
* Le message ne mentionne pas l'email pour ne pas révéler qu'une demande
* pour cette adresse a déjà été traitée.
*
* Note : le mock setUp() est reconfiguré ici via un nouveau mock pour éviter
* le conflit avec le willReturn(0) global — en PHPUnit 11 la première
* configuration prend la main sur les suivantes pour le même matcher any().
*/
public function testForgotRedirectsWhenRateLimited(): void
{
$authService = $this->createMock(AuthServiceInterface::class);
$authService->expects($this->once())
->method('checkPasswordResetRateLimit')
->with('203.0.113.5')
->willReturn(10);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
$this->clientIpResolver,
self::BASE_URL,
);
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('Trop de demandes'));
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$res = $controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() ne doit pas appeler requestReset() si l'IP est verrouillée.
*/
public function testForgotDoesNotCallServiceWhenRateLimited(): void
{
$authService = $this->createMock(AuthServiceInterface::class);
$authService->expects($this->once())
->method('checkPasswordResetRateLimit')
->with('203.0.113.5')
->willReturn(5);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
$this->clientIpResolver,
self::BASE_URL,
);
$this->passwordResetService->expects($this->never())->method('requestReset');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$controller->forgot($req, $this->makeResponse());
}
/**
* forgot() doit enregistrer une tentative pour chaque demande, quel que soit le résultat.
*
* Réinitialiser le compteur uniquement en cas de succès constituerait un canal
* caché permettant de déduire si l'adresse email est enregistrée.
*/
public function testForgotAlwaysRecordsFailure(): void
{
$this->authService->expects($this->once())
->method('recordPasswordResetAttempt')
->with('203.0.113.5');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$this->controller->forgot($req, $this->makeResponse());
}
/**
* forgot() ne doit jamais réinitialiser le compteur de rate limit.
*
* Un reset sur succès révélerait l'existence du compte (canal caché).
*/
public function testForgotNeverResetsRateLimit(): void
{
$this->authService->expects($this->never())->method('resetRateLimit');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$this->controller->forgot($req, $this->makeResponse());
}
// ── forgot — comportement nominal ────────────────────────────────
/**
* forgot() doit afficher un message de succès générique même si l'email est inconnu.
*
* Protection contre l'énumération des comptes : le comportement externe
* ne doit pas révéler si l'adresse est enregistrée.
*/
public function testForgotAlwaysShowsGenericSuccessMessage(): void
{
// requestReset() est void — aucun stub nécessaire, le mock ne lève pas d'exception par défaut
$this->flash->expects($this->once())->method('set')
->with('reset_success', $this->stringContains('Si cette adresse'));
$req = $this->makePost('/password/forgot', ['email' => 'unknown@example.com']);
$res = $this->controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() doit afficher une erreur et rediriger si l'envoi d'email échoue.
*/
public function testForgotRedirectsWithErrorOnMailFailure(): void
{
$this->passwordResetService->method('requestReset')
->willThrowException(new \RuntimeException('SMTP error'));
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('erreur est survenue'));
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$res = $this->controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() doit transmettre l'APP_URL au service lors de la demande.
*/
public function testForgotPassesBaseUrlToService(): void
{
$this->passwordResetService->expects($this->once())
->method('requestReset')
->with($this->anything(), self::BASE_URL);
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$this->controller->forgot($req, $this->makeResponse());
}
// ── showReset ────────────────────────────────────────────────────
/**
* showReset() doit rediriger avec une erreur si le paramètre token est absent.
*/
public function testShowResetRedirectsWhenTokenMissing(): void
{
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('manquant'));
$res = $this->controller->showReset($this->makeGet('/password/reset'), $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* showReset() doit rediriger avec une erreur si le token est invalide ou expiré.
*/
public function testShowResetRedirectsWhenTokenInvalid(): void
{
$this->passwordResetService->method('validateToken')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('invalide'));
$req = $this->makeGet('/password/reset', ['token' => 'invalid-token']);
$res = $this->controller->showReset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* showReset() doit rendre le formulaire si le token est valide.
*/
public function testShowResetRendersFormWhenTokenValid(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->passwordResetService->method('validateToken')->willReturn($user);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), '@Identity/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('12 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,264 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Infrastructure\PdoPasswordResetRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PdoPasswordResetRepository.
*
* Vérifie les opérations de persistance des tokens de réinitialisation.
*
* Les assertions privilégient l'intention (lecture, création, invalidation,
* consommation atomique) et les paramètres métier importants plutôt que
* la forme exacte du SQL.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PdoPasswordResetRepository $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 PdoPasswordResetRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
private function stmtOk(array|false $fetchReturn = false): MockObject&PDOStatement
{
$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->expects($this->once())->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->expects($this->once())->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->expects($this->once())->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 préparer une lecture sur password_resets
* puis lier le hash demandé.
*/
public function testFindActiveByHashFiltersOnNullUsedAt(): void
{
$tokenHash = hash('sha256', 'montokenbrut');
$stmt = $this->stmtOk(false);
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('token_hash'),
))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':token_hash' => $tokenHash]);
$this->repository->findActiveByHash($tokenHash);
}
// ── invalidateByUserId ─────────────────────────────────────────
/**
* invalidateByUserId() doit préparer une invalidation logique
* en renseignant :used_at pour les tokens de l'utilisateur.
*/
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
{
$userId = 42;
$stmt = $this->stmtOk();
$this->db->expects($this->once())->method('prepare')
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
))
->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 préparer une mise à jour ciblant
* les tokens actifs (used_at IS NULL) de password_resets pour
* l'utilisateur demandé.
*/
public function testInvalidateByUserIdFiltersOnActiveTokens(): void
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('user_id'),
$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->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE'))
->willReturn($stmt);
$this->db->expects($this->never())->method('exec');
$this->repository->invalidateByUserId(1);
}
// ── consumeActiveToken ────────────────────────────────────────
/**
* consumeActiveToken() doit préparer une consommation atomique du token
* et retourner la ligne correspondante si elle existe.
*/
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->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
$this->stringContains('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,91 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
use Netig\Netslim\Identity\Infrastructure\PdoLoginAttemptRepository;
use Netig\Netslim\Identity\Infrastructure\PdoPasswordResetRepository;
use Netig\Netslim\Identity\Infrastructure\PdoUserRepository;
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
use Netig\Netslim\Kernel\Persistence\Infrastructure\PdoTransactionManager;
use PDO;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetServiceIntegrationTest extends TestCase
{
private PDO $db;
private PasswordResetApplicationService $service;
private PdoUserRepository $users;
private PdoPasswordResetRepository $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 PdoUserRepository($this->db);
$this->resets = new PdoPasswordResetRepository($this->db);
$mail = new class () implements MailServiceInterface {
public function send(string $to, string $subject, string $template, array $context = []): void {}
};
$this->service = new PasswordResetApplicationService(
new RequestPasswordReset(
$this->resets,
$this->users,
$mail,
new PasswordResetTokenPolicy(),
new PdoLoginAttemptRepository($this->db),
new LoginRateLimitPolicy(),
),
new ValidatePasswordResetToken($this->resets, $this->users),
new ResetPassword(
$this->resets,
$this->users,
new PdoTransactionManager($this->db),
new PasswordPolicy(),
),
);
}
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void
{
$userId = $this->users->create(new User(0, 'alice', 'alice@example.com', password_hash('ancienpass12', 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, 'nouveaupass12');
$user = $this->users->findById($userId);
self::assertNotNull($user);
self::assertTrue(password_verify('nouveaupass12', $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,341 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetServiceTest extends TestCase
{
/** @var PasswordResetRepositoryInterface&MockObject */
private PasswordResetRepositoryInterface $resetRepository;
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var MailServiceInterface&MockObject */
private MailServiceInterface $mailService;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private PasswordResetApplicationService $service;
/** @var TransactionManagerInterface&MockObject */
private TransactionManagerInterface $transactionManager;
protected function setUp(): void
{
$this->resetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->transactionManager = $this->createMock(TransactionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new PasswordResetApplicationService(
new RequestPasswordReset(
$this->resetRepository,
$this->userRepository,
$this->mailService,
new PasswordResetTokenPolicy(),
$this->loginAttemptRepository,
new LoginRateLimitPolicy(),
),
new ValidatePasswordResetToken($this->resetRepository, $this->userRepository),
new ResetPassword(
$this->resetRepository,
$this->userRepository,
$this->transactionManager,
new PasswordPolicy(),
),
);
}
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://app.exemple.com');
}
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->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetCreatesTokenInDatabase(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create')
->with($user->getId(), $this->callback('is_string'), $this->callback('is_string'));
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetSendsEmailWithCorrectAddress(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send')
->with(
'alice@example.com',
$this->callback('is_string'),
'@Identity/emails/password-reset.twig',
$this->callback('is_array'),
);
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
}
public function testRequestResetUrlContainsToken(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->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://app.exemple.com');
}
public function testValidateTokenMissingToken(): void
{
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->willReturn(null);
$result = $this->service->validateToken('tokeninexistant');
$this->assertNull($result);
}
public function testValidateTokenExpiredToken(): void
{
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->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);
}
public function testValidateTokenValidToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->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->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$result = $this->service->validateToken($tokenRaw);
$this->assertSame($user, $result);
}
public function testValidateTokenDeletedUserReturnsNull(): void
{
$tokenRaw = 'token-valide-mais-user-supprime';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with(999)
->willReturn(null);
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
public function testResetPasswordInvalidToken(): void
{
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())->method('consumeActiveToken')->willReturn(null);
$this->expectException(InvalidResetTokenException::class);
$this->expectExceptionMessageMatches('/invalide ou a expiré/');
$this->service->resetPassword('tokeninvalide', 'nouveaumdp12');
}
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void
{
$this->expectException(WeakPasswordException::class);
$this->service->resetPassword('montokenbrut', '12345678901');
}
public function testResetPasswordPreservesWhitespace(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$newPassword = ' nouveaumdp12 ';
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())
->method('consumeActiveToken')
->with($tokenHash, $this->callback('is_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->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with($user->getId(), $this->callback(static function (string $hash) use ($newPassword): bool {
return password_verify($newPassword, $hash)
&& !password_verify(trim($newPassword), $hash);
}));
$this->service->resetPassword($tokenRaw, $newPassword);
}
public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->transactionManager->expects($this->once())
->method('run')
->willReturnCallback(function (callable $operation): mixed {
return $operation();
});
$this->resetRepository->expects($this->once())
->method('consumeActiveToken')
->with($tokenHash, $this->callback('is_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->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with($user->getId(), $this->callback('is_string'));
$this->service->resetPassword($tokenRaw, 'nouveaumdp12');
}
private function makeUser(): User
{
return new User(
1,
'alice',
'alice@example.com',
password_hash('motdepasse12', PASSWORD_BCRYPT),
);
}
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\UserServiceInterface;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\UI\Http\UserController;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;
/**
* Tests unitaires pour UserController.
*
* Couvre les 5 actions publiques :
* - index() : rendu de la liste
* - showCreate() : rendu du formulaire
* - create() : mismatch, username dupliqué, email dupliqué, mot de passe faible, succès
* - updateRole() : introuvable, propre rôle, cible admin, rôle invalide, succès
* - delete() : introuvable, cible admin, soi-même, succès
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class UserControllerTest extends ControllerTestBase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var UserServiceInterface&MockObject */
private UserServiceInterface $userService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private RolePolicy $rolePolicy;
private UserController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->userService = $this->createMock(UserServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->rolePolicy = new RolePolicy();
$this->controller = new UserController(
$this->view,
$this->userService,
$this->flash,
$this->sessionManager,
$this->rolePolicy,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit rendre la vue avec la liste des utilisateurs.
*/
public function testIndexRendersWithUserList(): void
{
$this->userService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
$this->sessionManager->method('getUserId')->willReturn(1);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), '@Identity/admin/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/users'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── showCreate ───────────────────────────────────────────────────
/**
* showCreate() doit rendre le formulaire de création.
*/
public function testShowCreateRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), '@Identity/admin/form.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showCreate($this->makeGet('/admin/users/create'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
*/
public function testCreateRedirectsWhenPasswordMismatch(): void
{
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Les mots de passe ne correspondent pas');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'pass1',
'password_confirm' => 'pass2',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() ne doit pas appeler userService si les mots de passe ne correspondent pas.
*/
public function testCreateDoesNotCallServiceOnMismatch(): void
{
$this->userService->expects($this->never())->method('create');
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'aaa',
'password_confirm' => 'bbb',
]);
$this->controller->create($req, $this->makeResponse());
}
/**
* create() doit rediriger avec une erreur si le nom d'utilisateur est déjà pris.
*/
public function testCreateRedirectsOnDuplicateUsername(): void
{
$this->userService->method('create')
->willThrowException(new DuplicateUsernameException('alice'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("nom d'utilisateur est déjà pris"));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si l'email est déjà utilisé.
*/
public function testCreateRedirectsOnDuplicateEmail(): void
{
$this->userService->method('create')
->willThrowException(new DuplicateEmailException('alice@example.com'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('e-mail est déjà utilisée'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si le mot de passe est trop court.
*/
public function testCreateRedirectsOnWeakPassword(): void
{
$this->userService->method('create')
->willThrowException(new WeakPasswordException());
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('12 caractères'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'short',
'password_confirm' => 'short',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit flasher un succès et rediriger vers /admin/users en cas de succès.
*/
public function testCreateRedirectsToUsersListOnSuccess(): void
{
$this->userService->method('create')->willReturn($this->makeUser(99, 'alice', User::ROLE_USER));
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('alice'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users');
}
/**
* create() doit forcer le rôle 'user' si un rôle admin est soumis dans le formulaire.
*/
public function testCreateForcesRoleUserWhenAdminRoleSubmitted(): void
{
$this->userService->expects($this->once())
->method('create')
->with('alice', 'alice@example.com', 'password123', User::ROLE_USER);
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
'role' => User::ROLE_ADMIN, // rôle injecté par l'attaquant
]);
$this->controller->create($req, $this->makeResponse());
}
// ── updateRole ───────────────────────────────────────────────────
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testUpdateRoleRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/99', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'admin tente de changer son propre rôle.
*/
public function testUpdateRoleRedirectsWhenAdminTriesToChangeOwnRole(): void
{
$user = $this->makeUser(1, 'admin', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre rôle'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/1', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur cible est déjà admin.
*/
public function testUpdateRoleRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("administrateur ne peut pas être modifié"));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si le rôle soumis est invalide.
*/
public function testUpdateRoleRedirectsOnInvalidRole(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Rôle invalide');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => 'superuser']),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit appeler userService et rediriger avec succès.
*/
public function testUpdateRoleRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('updateRole')->with(2, User::ROLE_EDITOR);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testDeleteRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si la cible est administrateur.
*/
public function testDeleteRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('administrateur ne peut pas être supprimé'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si l'admin tente de supprimer son propre compte.
*/
public function testDeleteRedirectsWhenAdminTriesToDeleteOwnAccount(): void
{
$user = $this->makeUser(1, 'alice', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre compte'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/1'),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit appeler userService et rediriger avec succès.
*/
public function testDeleteRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('delete')->with(2);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec les paramètres minimaux.
*/
private function makeUser(int $id, string $username, string $role): User
{
return new User($id, $username, "{$username}@example.com", password_hash('secret', PASSWORD_BCRYPT), $role);
}
}

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Infrastructure\PdoUserRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PdoUserRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class UserRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PdoUserRepository $repository;
/**
* Données représentant une ligne utilisateur en base de données.
*
* @var array<string, mixed>
*/
private array $rowAlice;
/**
* Initialise le mock PDO, le dépôt et les données de test avant chaque test.
*/
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PdoUserRepository($this->db);
$this->rowAlice = [
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
'role' => User::ROLE_USER,
'created_at' => '2024-01-01 00:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForWrite(): MockObject&PDOStatement
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() doit retourner un tableau vide si aucun utilisateur n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() doit retourner un tableau d'instances User hydratées.
*/
public function testFindAllReturnsUserInstances(): void
{
$stmt = $this->stmtForRead([$this->rowAlice]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(User::class, $result[0]);
$this->assertSame('alice', $result[0]->getUsername());
}
/**
* findAll() interroge bien la table `users`.
*/
public function testFindAllRequestsUsersQuery(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->stringContains('FROM users'))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() doit retourner null si aucun utilisateur ne correspond à cet identifiant.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() doit retourner une instance User hydratée si l'utilisateur existe.
*/
public function testFindByIdReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(User::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() doit exécuter avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array(42, $params, true)));
$this->repository->findById(42);
}
// ── findByUsername ─────────────────────────────────────────────
/**
* findByUsername() doit retourner null si le nom d'utilisateur est introuvable.
*/
public function testFindByUsernameReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByUsername('inconnu'));
}
/**
* findByUsername() doit retourner une instance User si le nom est trouvé.
*/
public function testFindByUsernameReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByUsername('alice');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice', $result->getUsername());
}
/**
* findByUsername() doit exécuter avec le bon nom d'utilisateur.
*/
public function testFindByUsernameQueriesWithCorrectName(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array('alice', $params, true)));
$this->repository->findByUsername('alice');
}
// ── findByEmail ────────────────────────────────────────────────
/**
* findByEmail() doit retourner null si l'adresse e-mail est introuvable.
*/
public function testFindByEmailReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByEmail('inconnu@example.com'));
}
/**
* findByEmail() doit retourner une instance User si l'e-mail est trouvé.
*/
public function testFindByEmailReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByEmail('alice@example.com');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice@example.com', $result->getEmail());
}
/**
* findByEmail() doit exécuter avec la bonne adresse e-mail.
*/
public function testFindByEmailQueriesWithCorrectEmail(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array('alice@example.com', $params, true)));
$this->repository->findByEmail('alice@example.com');
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit préparer un INSERT sur la table 'users' avec les bonnes données.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('INSERT INTO users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($user): bool {
return $data[':username'] === $user->getUsername()
&& $data[':email'] === $user->getEmail()
&& $data[':password_hash'] === $user->getPasswordHash()
&& $data[':role'] === $user->getRole()
&& isset($data[':created_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($user);
}
/**
* create() doit retourner l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('42');
$this->assertSame(42, $this->repository->create($user));
}
// ── updatePassword ─────────────────────────────────────────────
/**
* updatePassword() doit préparer un UPDATE avec le nouveau hash et le bon identifiant.
*/
public function testUpdatePasswordCallsUpdateWithCorrectHash(): void
{
$newHash = password_hash('nouveaumdp', PASSWORD_BCRYPT);
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array($newHash, $params, true) && in_array(1, $params, true)));
$this->repository->updatePassword(1, $newHash);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit préparer un UPDATE avec le bon rôle et le bon identifiant.
*/
public function testUpdateRoleCallsUpdateWithCorrectRole(): void
{
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array(User::ROLE_EDITOR, $params, true) && in_array(1, $params, true)));
$this->repository->updateRole(1, User::ROLE_EDITOR);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit préparer un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array(7, $params, true)));
$this->repository->delete(7);
}
}

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use Netig\Netslim\Identity\Application\UseCase\AdminDeleteUser;
use Netig\Netslim\Identity\Application\UseCase\AdminUpdateUserRole;
use Netig\Netslim\Identity\Application\UseCase\CreateUser;
use Netig\Netslim\Identity\Application\UseCase\DeleteUser;
use Netig\Netslim\Identity\Application\UseCase\UpdateUserRole;
use Netig\Netslim\Identity\Application\UserApplicationService;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
use Netig\Netslim\Identity\Domain\Exception\InvalidRoleException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserApplicationService.
*
* Vérifie la création de compte : normalisation, unicité du nom d'utilisateur
* et de l'email, validation de la complexité du mot de passe.
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
* isoler le service.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class UserServiceTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private UserApplicationService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$rolePolicy = new RolePolicy();
$passwordPolicy = new PasswordPolicy();
$this->service = new UserApplicationService(
$this->userRepository,
new CreateUser($this->userRepository, $rolePolicy, $passwordPolicy),
new UpdateUserRole($this->userRepository, $rolePolicy),
new DeleteUser($this->userRepository),
new AdminUpdateUserRole($this->userRepository, $rolePolicy),
new AdminDeleteUser($this->userRepository),
);
}
// ── create ─────────────────────────────────────────────────
/**
* create() doit créer et retourner un utilisateur avec les bonnes données.
*/
public function testCreateUserWithValidData(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->expects($this->once())->method('create');
$user = $this->service->create('Alice', 'alice@example.com', 'motdepasse12');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* create() doit normaliser le nom d'utilisateur et l'email en minuscules.
*/
public function testCreateUserNormalizesToLowercase(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->create(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse12');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
}
/**
* create() doit lever DuplicateUsernameException si le nom est déjà pris.
*/
public function testCreateUserDuplicateUsernameThrowsDuplicateUsernameException(): void
{
$existingUser = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn($existingUser);
$this->expectException(DuplicateUsernameException::class);
$this->service->create('alice', 'autre@example.com', 'motdepasse12');
}
/**
* create() doit lever DuplicateEmailException si l'email est déjà utilisé.
*/
public function testCreateUserDuplicateEmailThrowsDuplicateEmailException(): void
{
$existingUser = $this->makeUser('bob', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn($existingUser);
$this->expectException(DuplicateEmailException::class);
$this->service->create('newuser', 'alice@example.com', 'motdepasse12');
}
/**
* create() doit lever WeakPasswordException si le mot de passe est trop court.
*/
public function testCreateUserTooShortPasswordThrowsWeakPasswordException(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->expectException(WeakPasswordException::class);
$this->service->create('alice', 'alice@example.com', '12345678901');
}
/**
* create() avec exactement 12 caractères de mot de passe doit réussir.
*/
public function testCreateUserMinimumPasswordLength(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->create('alice', 'alice@example.com', '123456789012');
$this->assertInstanceOf(User::class, $user);
}
/**
* create() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testCreateUserPasswordIsHashed(): void
{
$plainPassword = 'motdepasse12';
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->create('alice', 'alice@example.com', $plainPassword);
$this->assertNotSame($plainPassword, $user->getPasswordHash());
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
}
/**
* create() doit conserver exactement les espaces du mot de passe saisi.
*/
public function testCreateUserPreservesPasswordWhitespace(): void
{
$plainPassword = ' motdepasse12 ';
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->create('alice', 'alice@example.com', $plainPassword);
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
$this->assertFalse(password_verify(trim($plainPassword), $user->getPasswordHash()));
}
/**
* create() doit attribuer le rôle passé en paramètre.
*/
public function testCreateUserWithEditorRole(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->create('alice', 'alice@example.com', 'motdepasse12', User::ROLE_EDITOR);
$this->assertSame(User::ROLE_EDITOR, $user->getRole());
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() délègue au repository et retourne la liste.
*/
public function testFindAllDelegatesToRepository(): void
{
$users = [$this->makeUser('alice', 'alice@example.com')];
$this->userRepository->method('findAll')->willReturn($users);
$this->assertSame($users, $this->service->findAll());
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si l'utilisateur est introuvable.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->userRepository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(99));
}
/**
* findById() retourne l'utilisateur trouvé.
*/
public function testFindByIdReturnsUser(): void
{
$user = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
$this->assertSame($user, $this->service->findById(1));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() délègue la suppression au repository.
*/
public function testDeleteDelegatesToRepository(): void
{
$this->userRepository->expects($this->once())->method('findById')->with(5)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('delete')->with(5);
$this->service->delete(5);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit déléguer au repository avec le rôle validé.
*/
public function testUpdateRoleDelegatesToRepository(): void
{
$this->userRepository->expects($this->once())->method('findById')->with(3)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())
->method('updateRole')
->with(3, User::ROLE_EDITOR);
$this->service->updateRole(3, User::ROLE_EDITOR);
}
/**
* updateRole() doit lever InvalidArgumentException pour un rôle inconnu.
*/
public function testUpdateRoleThrowsOnInvalidRole(): void
{
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(InvalidRoleException::class);
$this->service->updateRole(1, 'superadmin');
}
/**
* updateRole() accepte uniquement les rôles attribuables depuis l'interface.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
public function testUpdateRoleAcceptsAllValidRoles(string $role): void
{
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('updateRole')->with(1, $role);
$this->service->updateRole(1, $role);
}
/**
* @return array<string, array{string}>
*/
public static function validRolesProvider(): array
{
return [
'user' => [User::ROLE_USER],
'editor' => [User::ROLE_EDITOR],
];
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
*/
private function makeUser(string $username, string $email): User
{
return new User(1, $username, $email, password_hash('motdepasse12', PASSWORD_BCRYPT));
}
}

233
tests/Identity/UserTest.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Tests\Identity;
use DateTime;
use InvalidArgumentException;
use Netig\Netslim\Identity\Domain\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour le modèle User.
*
* Vérifie la construction, la validation, les accesseurs
* et l'hydratation depuis un tableau de base de données.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class UserTest extends TestCase
{
// ── Construction valide ────────────────────────────────────────
/**
* Un utilisateur construit avec des données valides ne doit pas lever d'exception.
*/
public function testValidConstruction(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret123', PASSWORD_BCRYPT));
$this->assertSame(1, $user->getId());
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* Le rôle par défaut doit être 'user'.
*/
public function testDefaultRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame(User::ROLE_USER, $user->getRole());
$this->assertFalse($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
*/
public function testAdminRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_ADMIN);
$this->assertTrue($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
*/
public function testEditorRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_EDITOR);
$this->assertFalse($user->isAdmin());
$this->assertTrue($user->isEditor());
}
/**
* Une date de création explicite doit être conservée.
*/
public function testExplicitCreationDate(): void
{
$date = new DateTime('2024-01-15 10:00:00');
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_USER, $date);
$this->assertEquals($date, $user->getCreatedAt());
}
/**
* Sans date explicite, la date de création doit être définie à maintenant.
*/
public function testDefaultCreationDate(): void
{
$before = new DateTime();
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$after = new DateTime();
$this->assertGreaterThanOrEqual($before, $user->getCreatedAt());
$this->assertLessThanOrEqual($after, $user->getCreatedAt());
}
// ── Validation — nom d'utilisateur ─────────────────────────────
/**
* Un nom d'utilisateur de moins de 3 caractères doit lever une exception.
*/
public function testUsernameTooShort(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/3 caractères/');
new User(1, 'ab', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de plus de 50 caractères doit lever une exception.
*/
public function testUsernameTooLong(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/50 caractères/');
new User(1, str_repeat('a', 51), 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de exactement 3 caractères doit être accepté.
*/
public function testUsernameMinimumLength(): void
{
$user = new User(1, 'ali', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame('ali', $user->getUsername());
}
/**
* Un nom d'utilisateur de exactement 50 caractères doit être accepté.
*/
public function testUsernameMaximumLength(): void
{
$username = str_repeat('a', 50);
$user = new User(1, $username, 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame($username, $user->getUsername());
}
// ── Validation — email ─────────────────────────────────────────
/**
* Un email invalide doit lever une exception.
*/
public function testInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/email/i');
new User(1, 'alice', 'pas-un-email', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un email vide doit lever une exception.
*/
public function testEmptyEmail(): void
{
$this->expectException(InvalidArgumentException::class);
new User(1, 'alice', '', password_hash('secret', PASSWORD_BCRYPT));
}
// ── Validation — hash du mot de passe ──────────────────────────
/**
* Un hash de mot de passe vide doit lever une exception.
*/
public function testEmptyPasswordHash(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/hash/i');
new User(1, 'alice', 'alice@example.com', '');
}
// ── Validation — rôle ──────────────────────────────────────────
/**
* Un rôle invalide doit lever une exception.
*/
public function testInvalidRole(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/rôle/i');
new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), 'superadmin');
}
// ── Hydratation depuis un tableau ──────────────────────────────
/**
* fromArray() doit hydrater correctement l'utilisateur depuis une ligne de base de données.
*/
public function testFromArray(): void
{
$hash = password_hash('secret', PASSWORD_BCRYPT);
$user = User::fromArray([
'id' => 42,
'username' => 'bob',
'email' => 'bob@example.com',
'password_hash' => $hash,
'role' => 'editor',
'created_at' => '2024-06-01 12:00:00',
]);
$this->assertSame(42, $user->getId());
$this->assertSame('bob', $user->getUsername());
$this->assertSame('bob@example.com', $user->getEmail());
$this->assertSame($hash, $user->getPasswordHash());
$this->assertSame('editor', $user->getRole());
$this->assertTrue($user->isEditor());
}
/**
* fromArray() avec une date absente ne doit pas lever d'exception.
*/
public function testFromArrayWithoutDate(): void
{
$user = User::fromArray([
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
]);
$this->assertInstanceOf(DateTime::class, $user->getCreatedAt());
}
}