throttle(); for ($i = 0; $i < 4; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertFalse($t->isLocked('admin', '10.0.0.1')); } public function testFifthFailureLocksForOneMinute(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testTenthFailureLocksForFiveMinutes(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 10; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testFifteenthFailureLocksForThirtyMinutes(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 15; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testLockoutExpiresAfterTimeAdvance(): void { $now = 1000000; $t = new LoginThrottle($this->logger(), function () use (&$now): int { return $now; }); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); $now += 61; self::assertFalse($t->isLocked('admin', '10.0.0.1')); } public function testDifferentIpHasIndependentBucket(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); self::assertFalse($t->isLocked('admin', '10.0.0.2')); } public function testClearResetsBucket(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } $t->clear('admin', '10.0.0.1'); self::assertFalse($t->isLocked('admin', '10.0.0.1')); self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testUsernameCaseDoesNotMultiplyBuckets(): void { $t = $this->throttle(); for ($i = 0; $i < 3; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } for ($i = 0; $i < 2; ++$i) { $t->recordFailure('ADMIN', '10.0.0.1'); } self::assertTrue($t->isLocked('Admin', '10.0.0.1')); } public function testPerUsernameBucketLocksOutAcrossDistinctIps(): void { $t = $this->throttle(); // 24 failures from 24 distinct IPs — each is a fresh per-IP bucket // (only 1 attempt apiece), but the per-username counter accumulates. for ($i = 0; $i < 24; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertFalse($t->isLocked('admin', '10.0.0.99')); // 25th attempt from yet another IP trips the per-username lockout. $t->recordFailure('admin', '10.0.0.99'); self::assertTrue($t->isLocked('admin', '10.0.1.1')); } public function testPerUsernameLadderProgresses(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 25; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(60, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); for ($i = 25; $i < 50; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); for ($i = 50; $i < 100; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); } public function testLockoutSecondsRemainingReturnsLargerOfBuckets(): void { $now = 1000000; $t = $this->throttle($now); // 5 failures from one IP → per-IP locked for 60s. for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } // Plus 45 more from distinct IPs → per-username at count=50 → 300s. for ($i = 0; $i < 45; ++$i) { $t->recordFailure('admin', '10.1.0.' . $i); } // Asked from yet-another-IP, only the per-username lock applies — 300s. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); // Asked from the per-IP bucket's IP, 300s still wins (max of 60/300). self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testClearResetsBothBuckets(): void { $t = $this->throttle(); // Build per-username pressure. for ($i = 0; $i < 25; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertTrue($t->isLocked('admin', '10.99.0.1')); $t->clear('admin', '10.0.0.0'); self::assertFalse($t->isLocked('admin', '10.99.0.1')); self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); } public function testEmptyUsernameStillBucketsPerUsername(): void { $t = $this->throttle(); for ($i = 0; $i < 25; ++$i) { $t->recordFailure('', '10.0.0.' . $i); } self::assertTrue($t->isLocked('', '10.99.0.1')); } private function throttle(?int $fixedTime = null): LoginThrottle { if ($fixedTime === null) { return new LoginThrottle($this->logger()); } return new LoginThrottle($this->logger(), static fn (): int => $fixedTime); } private function logger(): LoggerInterface { $l = new Logger('test'); $l->pushHandler(new NullHandler()); return $l; } }