|
|
@@ -0,0 +1,223 @@
|
|
|
+<?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'))
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|