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(); } }