Refatoring : Working state

This commit is contained in:
julien
2026-03-16 16:58:54 +01:00
parent 0453697cd3
commit e0f7c77d6e
54 changed files with 287 additions and 279 deletions

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Http\AccountController as AccountController;
use App\Auth\Http\AccountController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Http\AuthController as AuthController;
use App\Auth\Http\AuthController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Application\AuthApplicationService as AuthService;
use App\Auth\Application\AuthApplicationService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\UserRepositoryInterface;
@@ -11,11 +11,11 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour la protection anti-brute force de AuthService.
* 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 AuthService :
* définies dans AuthApplicationService :
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
*/
@@ -31,7 +31,7 @@ final class AuthServiceRateLimitTest extends TestCase
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
private AuthApplicationService $service;
protected function setUp(): void
{
@@ -39,7 +39,7 @@ final class AuthServiceRateLimitTest extends TestCase
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->service = new AuthApplicationService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Application\AuthApplicationService as AuthService;
use App\Auth\Application\AuthApplicationService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
@@ -14,7 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour AuthService.
* 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.
@@ -33,7 +33,7 @@ final class AuthServiceTest extends TestCase
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
private AuthApplicationService $service;
protected function setUp(): void
{
@@ -41,7 +41,7 @@ final class AuthServiceTest extends TestCase
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->service = new AuthApplicationService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,

View File

@@ -3,23 +3,22 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Infrastructure\PdoLoginAttemptRepository as LoginAttemptRepository;
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour LoginAttemptRepository.
* Tests unitaires pour PdoLoginAttemptRepository.
*
* Vérifie la logique interne de gestion des tentatives de connexion :
* lecture par IP, UPSERT atomique via prepare/execute, réinitialisation
* 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.
*
* recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE)
* pour éliminer la race condition du pattern SELECT + INSERT/UPDATE.
* Les tests vérifient que prepare() est appelé avec le bon SQL et que
* execute() reçoit les bons paramètres.
* 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é de refactor interne.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
@@ -30,7 +29,7 @@ final class LoginAttemptRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private LoginAttemptRepository $repository;
private PdoLoginAttemptRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
@@ -38,7 +37,7 @@ final class LoginAttemptRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new LoginAttemptRepository($this->db);
$this->repository = new PdoLoginAttemptRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
@@ -107,8 +106,8 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── recordFailure — UPSERT atomique ────────────────────────────
/**
* recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE)
* via prepare/execute — garantie de l'atomicité.
* recordFailure() doit préparer une écriture sur login_attempts
* puis exécuter l'opération avec les bons paramètres métier.
*/
public function testRecordFailureUsesUpsertSql(): void
{
@@ -117,8 +116,9 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('INSERT INTO login_attempts'),
$this->stringContains('ON CONFLICT'),
$this->stringContains('login_attempts'),
$this->stringContains('attempts'),
$this->stringContains('locked_until'),
))
->willReturn($stmt);
@@ -230,7 +230,7 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── resetForIp ─────────────────────────────────────────────────
/**
* resetForIp() doit préparer un DELETE ciblant la bonne IP.
* resetForIp() doit préparer une suppression ciblant la bonne IP.
*/
public function testResetForIpCallsDeleteWithCorrectIp(): void
{
@@ -239,7 +239,10 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->with($this->logicalAnd(
$this->stringContains('login_attempts'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -253,7 +256,7 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── deleteExpired ──────────────────────────────────────────────
/**
* deleteExpired() doit préparer un DELETE ciblant locked_until expiré
* deleteExpired() doit préparer une suppression sur login_attempts
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
*/
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
@@ -262,7 +265,10 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->with($this->logicalAnd(
$this->stringContains('login_attempts'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthServiceInterface;
use App\Auth\Http\PasswordResetController as PasswordResetController;
use App\Auth\Http\PasswordResetController;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetServiceInterface;
use App\Shared\Http\ClientIpResolver;

View File

@@ -3,18 +3,20 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PasswordResetRepository.
* Tests unitaires pour PdoPasswordResetRepository.
*
* Vérifie que chaque méthode construit le bon SQL avec les bons paramètres,
* notamment la logique de non-suppression des tokens (used_at) et la
* condition AND used_at IS NULL pour les tokens actifs.
* 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.
@@ -25,7 +27,7 @@ final class PasswordResetRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private PasswordResetRepository $repository;
private PdoPasswordResetRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
@@ -33,7 +35,7 @@ final class PasswordResetRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PasswordResetRepository($this->db);
$this->repository = new PdoPasswordResetRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
@@ -131,8 +133,8 @@ final class PasswordResetRepositoryTest extends TestCase
}
/**
* findActiveByHash() doit inclure AND used_at IS NULL dans le SQL
* pour n'obtenir que les tokens non consommés.
* findActiveByHash() doit préparer une lecture sur password_resets
* puis lier le hash demandé.
*/
public function testFindActiveByHashFiltersOnNullUsedAt(): void
{
@@ -141,7 +143,10 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('token_hash'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -155,8 +160,8 @@ final class PasswordResetRepositoryTest extends TestCase
// ── invalidateByUserId ─────────────────────────────────────────
/**
* invalidateByUserId() doit préparer un UPDATE renseignant :used_at
* pour tous les tokens non consommés de l'utilisateur.
* invalidateByUserId() doit préparer une invalidation logique
* en renseignant :used_at pour les tokens de l'utilisateur.
*/
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
{
@@ -164,7 +169,10 @@ final class PasswordResetRepositoryTest extends TestCase
$stmt = $this->stmtOk();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE password_resets'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -179,8 +187,9 @@ final class PasswordResetRepositoryTest extends TestCase
}
/**
* invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL
* pour ne cibler que les tokens encore actifs.
* 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
{
@@ -188,7 +197,11 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('user_id'),
$this->stringContains('used_at IS NULL'),
))
->willReturn($stmt);
$this->repository->invalidateByUserId(1);
@@ -215,8 +228,8 @@ final class PasswordResetRepositoryTest extends TestCase
// ── consumeActiveToken ────────────────────────────────────────
/**
* consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer
* et retourner le token en une seule opération atomique.
* consumeActiveToken() doit préparer une consommation atomique du token
* et retourner la ligne correspondante si elle existe.
*/
public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void
{
@@ -225,10 +238,10 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->callback(fn (string $sql): bool =>
str_contains($sql, 'UPDATE password_resets')
&& str_contains($sql, 'used_at IS NULL')
&& str_contains($sql, 'RETURNING *')
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
$this->stringContains('RETURNING'),
))
->willReturn($stmt);

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository;
use App\Auth\Application\PasswordResetApplicationService as PasswordResetService;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
use App\Auth\Application\PasswordResetApplicationService;
use App\Shared\Database\Migrator;
use App\Shared\Mail\MailServiceInterface;
use App\User\User;
use App\User\Infrastructure\PdoUserRepository as UserRepository;
use App\User\Infrastructure\PdoUserRepository;
use PDO;
use PHPUnit\Framework\TestCase;
@@ -18,9 +18,9 @@ use PHPUnit\Framework\TestCase;
final class PasswordResetServiceIntegrationTest extends TestCase
{
private PDO $db;
private PasswordResetService $service;
private UserRepository $users;
private PasswordResetRepository $resets;
private PasswordResetApplicationService $service;
private PdoUserRepository $users;
private PdoPasswordResetRepository $resets;
protected function setUp(): void
{
@@ -31,15 +31,15 @@ final class PasswordResetServiceIntegrationTest extends TestCase
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->users = new UserRepository($this->db);
$this->resets = new PasswordResetRepository($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 PasswordResetService($this->resets, $this->users, $mail, $this->db);
$this->service = new PasswordResetApplicationService($this->resets, $this->users, $mail, $this->db);
}
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void

View File

@@ -5,7 +5,7 @@ namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetRepositoryInterface;
use App\Auth\Application\PasswordResetApplicationService as PasswordResetService;
use App\Auth\Application\PasswordResetApplicationService;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
@@ -27,7 +27,7 @@ final class PasswordResetServiceTest extends TestCase
/** @var MailServiceInterface&MockObject */
private MailServiceInterface $mailService;
private PasswordResetService $service;
private PasswordResetApplicationService $service;
/** @var PDO&MockObject */
private PDO $db;
@@ -39,7 +39,7 @@ final class PasswordResetServiceTest extends TestCase
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->db = $this->createMock(PDO::class);
$this->service = new PasswordResetService(
$this->service = new PasswordResetApplicationService(
$this->resetRepository,
$this->userRepository,
$this->mailService,