makeDb()); $this->assertNull( $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:00:00Z')) ); } public function testFirstFourFailuresDoNotLock(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 4; $i++) { $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); $this->assertNull($lock, "attempt {$i} must not lock"); } $this->assertNull( $repo->lockoutFor('1.2.3.4', 'admin@example.com', $now) ); } public function testFifthFailureLocksForFiveMinutes(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 4; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); } $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); $this->assertNotNull($lock); $this->assertSame( '2026-05-07T10:05:00Z', $lock->format('Y-m-d\TH:i:s\Z') ); } public function testTenthFailureEscalatesToThirtyMinutes(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 9; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); } $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); $this->assertNotNull($lock); $this->assertSame( '2026-05-07T10:30:00Z', $lock->format('Y-m-d\TH:i:s\Z') ); } public function testTwentiethFailureEscalatesToOneHour(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 19; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); } $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); $this->assertNotNull($lock); $this->assertSame( '2026-05-07T11:00:00Z', $lock->format('Y-m-d\TH:i:s\Z') ); } public function testLockoutExpiresAfterDuration(): void { $repo = new AuthThrottleRepository($this->makeDb()); $start = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 5; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $start); } // Locked at +0..+4:59 $this->assertNotNull( $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:04:59Z')) ); // Unlocked at +5:00 (lock_until is exclusive: until > now ⇒ locked). $this->assertNull( $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:05:00Z')) ); } public function testCounterResetsAfterIdleWindow(): void { $repo = new AuthThrottleRepository($this->makeDb()); $start = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 4; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $start); } // 16 minutes later → window is stale (15-min idle threshold). $afterIdle = self::at('2026-05-07T10:16:00Z'); $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $afterIdle); $this->assertNull($lock, 'counter must reset to 1 after idle window'); } public function testCounterDoesNotResetWithinWindow(): void { $repo = new AuthThrottleRepository($this->makeDb()); $start = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 4; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $start); } // 14 minutes later → still inside the window. $stillInside = self::at('2026-05-07T10:14:00Z'); $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $stillInside); $this->assertNotNull($lock, 'fifth attempt within the window must lock'); } public function testClearRemovesRow(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 5; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); } $this->assertNotNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now)); $repo->clear('1.2.3.4', 'admin@example.com'); $this->assertNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now)); $next = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); $this->assertNull($next, 'after clear the next single failure must not lock'); } public function testKeysAreCaseInsensitiveOnEmail(): void { // An attacker varying the case of the email shouldn't get extra // attempts (`Admin@…` vs `admin@…`); the bucket is canonicalised. $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 4; $i++) { $repo->recordFailure('1.2.3.4', 'Admin@example.com', $now); } $lock = $repo->recordFailure('1.2.3.4', 'admin@EXAMPLE.com', $now); $this->assertNotNull($lock, 'case-varied email must hit the same bucket'); } public function testIpsBucketSeparately(): void { $repo = new AuthThrottleRepository($this->makeDb()); $now = self::at('2026-05-07T10:00:00Z'); for ($i = 1; $i <= 5; $i++) { $repo->recordFailure('1.2.3.4', 'admin@example.com', $now); } // Different IP → not throttled even though the same email is // currently locked from another source. $this->assertNull( $repo->lockoutFor('5.6.7.8', 'admin@example.com', $now), 'a second attacker IP must not inherit the first IP\'s lock' ); } public function testComputeLockoutPolicyMatrix(): void { $now = self::at('2026-05-07T10:00:00Z'); $this->assertNull(AuthThrottleRepository::computeLockout(0, $now)); $this->assertNull(AuthThrottleRepository::computeLockout(4, $now)); $r5 = AuthThrottleRepository::computeLockout(5, $now); $this->assertNotNull($r5); $this->assertSame('2026-05-07T10:05:00Z', $r5->format('Y-m-d\TH:i:s\Z')); $r9 = AuthThrottleRepository::computeLockout(9, $now); $this->assertNotNull($r9); $this->assertSame('2026-05-07T10:05:00Z', $r9->format('Y-m-d\TH:i:s\Z')); $r10 = AuthThrottleRepository::computeLockout(10, $now); $this->assertNotNull($r10); $this->assertSame('2026-05-07T10:30:00Z', $r10->format('Y-m-d\TH:i:s\Z')); $r19 = AuthThrottleRepository::computeLockout(19, $now); $this->assertNotNull($r19); $this->assertSame('2026-05-07T10:30:00Z', $r19->format('Y-m-d\TH:i:s\Z')); $r20 = AuthThrottleRepository::computeLockout(20, $now); $this->assertNotNull($r20); $this->assertSame('2026-05-07T11:00:00Z', $r20->format('Y-m-d\TH:i:s\Z')); $r99 = AuthThrottleRepository::computeLockout(99, $now); $this->assertNotNull($r99); $this->assertSame('2026-05-07T11:00:00Z', $r99->format('Y-m-d\TH:i:s\Z')); } public function testPurgeExpiredDropsOnlyStaleRows(): void { $repo = new AuthThrottleRepository($this->makeDb()); $repo->recordFailure('1.2.3.4', 'a@x', self::at('2026-05-07T08:00:00Z')); $repo->recordFailure('1.2.3.4', 'b@x', self::at('2026-05-07T11:00:00Z')); // Cutoff at 10:00 — 'a@x' (08:00) is older, 'b@x' (11:00) is fresh. $deleted = $repo->purgeExpired(self::at('2026-05-07T10:00:00Z')); $this->assertSame(1, $deleted); $this->assertNull( $repo->lockoutFor('1.2.3.4', 'a@x', self::at('2026-05-07T11:30:00Z')) ); } }