first commit
This commit is contained in:
276
tests/Auth/LoginAttemptRepositoryTest.php
Normal file
276
tests/Auth/LoginAttemptRepositoryTest.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Auth;
|
||||
|
||||
use App\Auth\LoginAttemptRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour LoginAttemptRepository.
|
||||
*
|
||||
* Vérifie la logique interne de gestion des tentatives de connexion :
|
||||
* lecture par IP, UPSERT atomique via prepare/execute, réinitialisation
|
||||
* et nettoyage des entrées expirées.
|
||||
*
|
||||
* recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE)
|
||||
* pour éliminer la race condition du pattern SELECT + INSERT/UPDATE.
|
||||
* Les tests vérifient que prepare() est appelé avec le bon SQL et que
|
||||
* execute() reçoit les bons paramètres.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
final class LoginAttemptRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private LoginAttemptRepository $repository;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO et le dépôt avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new LoginAttemptRepository($this->db);
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────
|
||||
|
||||
private function stmtOk(): PDOStatement&MockObject
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findByIp ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner null si aucune entrée n'existe pour cette IP.
|
||||
*/
|
||||
public function testFindByIpReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner le tableau associatif si une entrée existe.
|
||||
*/
|
||||
public function testFindByIpReturnsRowWhenFound(): void
|
||||
{
|
||||
$row = [
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 3,
|
||||
'locked_until' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame($row, $this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() exécute avec la bonne IP.
|
||||
*/
|
||||
public function testFindByIpQueriesWithCorrectIp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':ip' => '10.0.0.1']);
|
||||
|
||||
$this->repository->findByIp('10.0.0.1');
|
||||
}
|
||||
|
||||
|
||||
// ── recordFailure — UPSERT atomique ────────────────────────────
|
||||
|
||||
/**
|
||||
* recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE)
|
||||
* via prepare/execute — garantie de l'atomicité.
|
||||
*/
|
||||
public function testRecordFailureUsesUpsertSql(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('INSERT INTO login_attempts'),
|
||||
$this->stringContains('ON CONFLICT'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer la bonne IP au paramètre :ip.
|
||||
*/
|
||||
public function testRecordFailurePassesCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool => $p[':ip'] === $ip));
|
||||
|
||||
$this->repository->recordFailure($ip, 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2.
|
||||
*
|
||||
* PDO interdit les paramètres nommés dupliqués dans une même requête ;
|
||||
* le seuil est donc passé deux fois avec des noms distincts.
|
||||
*/
|
||||
public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void
|
||||
{
|
||||
$max = 7;
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool =>
|
||||
$p[':max1'] === $max && $p[':max2'] === $max
|
||||
));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', $max, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit calculer locked_until dans la fenêtre attendue.
|
||||
*
|
||||
* :lock1 et :lock2 doivent être égaux et correspondre à
|
||||
* maintenant + lockMinutes (± 5 secondes de tolérance d'exécution).
|
||||
*/
|
||||
public function testRecordFailureLockedUntilIsInCorrectWindow(): void
|
||||
{
|
||||
$lockMinutes = 15;
|
||||
$before = time() + $lockMinutes * 60 - 5;
|
||||
$after = time() + $lockMinutes * 60 + 5;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':lock1', ':lock2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':lock1'] === $p[':lock2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, $lockMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent.
|
||||
*/
|
||||
public function testRecordFailureNowParamsAreCurrentTime(): void
|
||||
{
|
||||
$before = time() - 2;
|
||||
$after = time() + 2;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':now1', ':now2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':now1'] === $p[':now2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
|
||||
// ── resetForIp ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* resetForIp() doit préparer un DELETE ciblant la bonne IP.
|
||||
*/
|
||||
public function testResetForIpCallsDeleteWithCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM login_attempts'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':ip' => $ip]);
|
||||
|
||||
$this->repository->resetForIp($ip);
|
||||
}
|
||||
|
||||
|
||||
// ── deleteExpired ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* deleteExpired() doit préparer un DELETE ciblant locked_until expiré
|
||||
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
|
||||
*/
|
||||
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM login_attempts'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $params): bool {
|
||||
return isset($params[':now'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $params[':now']);
|
||||
}));
|
||||
|
||||
$this->repository->deleteExpired();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user