278 lines
9.1 KiB
PHP
278 lines
9.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Auth;
|
|
|
|
use App\Auth\Infrastructure\PdoLoginAttemptRepository as 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.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
|
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();
|
|
}
|
|
}
|