Files
slim-blog/tests/Auth/LoginAttemptRepositoryTest.php
2026-03-16 16:58:54 +01:00

284 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\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é de refactor 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(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn(false);
return $stmt;
}
// ── findByIp ───────────────────────────────────────────────────
/**
* findByIp() doit retourner null si aucune entrée n'existe pour cette IP.
*/
public function testFindByIpReturnsNullWhenMissing(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByIp('192.168.1.1'));
}
/**
* findByIp() doit retourner le tableau associatif si une entrée existe.
*/
public function testFindByIpReturnsRowWhenFound(): void
{
$row = [
'ip' => '192.168.1.1',
'attempts' => 3,
'locked_until' => null,
'updated_at' => date('Y-m-d H:i:s'),
];
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn($row);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame($row, $this->repository->findByIp('192.168.1.1'));
}
/**
* findByIp() exécute avec la bonne IP.
*/
public function testFindByIpQueriesWithCorrectIp(): void
{
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':ip' => '10.0.0.1']);
$this->repository->findByIp('10.0.0.1');
}
// ── recordFailure — UPSERT atomique ────────────────────────────
/**
* recordFailure() doit préparer une écriture sur login_attempts
* 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('login_attempts'),
$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[':ip'] === $ip));
$this->repository->recordFailure($ip, 5, 15);
}
/**
* recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2.
*
* PDO interdit les paramètres nommés dupliqués dans une même requête ;
* le seuil est donc passé deux fois avec des noms distincts.
*/
public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void
{
$max = 7;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
$p[':max1'] === $max && $p[':max2'] === $max
));
$this->repository->recordFailure('192.168.1.1', $max, 15);
}
/**
* recordFailure() doit calculer locked_until dans la fenêtre attendue.
*
* :lock1 et :lock2 doivent être égaux et correspondre à
* maintenant + lockMinutes (± 5 secondes de tolérance d'exécution).
*/
public function testRecordFailureLockedUntilIsInCorrectWindow(): void
{
$lockMinutes = 15;
$before = time() + $lockMinutes * 60 - 5;
$after = time() + $lockMinutes * 60 + 5;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $p) use ($before, $after): bool {
foreach ([':lock1', ':lock2'] as $key) {
if (!isset($p[$key])) {
return false;
}
$ts = strtotime($p[$key]);
if ($ts < $before || $ts > $after) {
return false;
}
}
return $p[':lock1'] === $p[':lock2'];
}));
$this->repository->recordFailure('192.168.1.1', 5, $lockMinutes);
}
/**
* recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent.
*/
public function testRecordFailureNowParamsAreCurrentTime(): void
{
$before = time() - 2;
$after = time() + 2;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $p) use ($before, $after): bool {
foreach ([':now1', ':now2'] as $key) {
if (!isset($p[$key])) {
return false;
}
$ts = strtotime($p[$key]);
if ($ts < $before || $ts > $after) {
return false;
}
}
return $p[':now1'] === $p[':now2'];
}));
$this->repository->recordFailure('192.168.1.1', 5, 15);
}
// ── resetForIp ─────────────────────────────────────────────────
/**
* resetForIp() doit préparer 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('login_attempts'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':ip' => $ip]);
$this->repository->resetForIp($ip);
}
// ── deleteExpired ──────────────────────────────────────────────
/**
* 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
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('login_attempts'),
$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();
}
}