| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Repositories;
- use App\Repositories\AuthThrottleRepository;
- use App\Tests\TestCase;
- use DateTimeImmutable;
- use DateTimeZone;
- /**
- * R01-N06: persistent (ip, email) throttle for /auth/local. Tests pin the
- * lock policy matrix and the window-rollover behaviour without sleeping —
- * the production code accepts an injected $now for exactly this reason.
- */
- final class AuthThrottleRepositoryTest extends TestCase
- {
- private static function at(string $iso): DateTimeImmutable
- {
- return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
- }
- public function testNoLockoutWhenRowMissing(): void
- {
- $repo = new AuthThrottleRepository($this->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'))
- );
- }
- }
|