AuthThrottleRepositoryTest.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Repositories;
  4. use App\Repositories\AuthThrottleRepository;
  5. use App\Tests\TestCase;
  6. use DateTimeImmutable;
  7. use DateTimeZone;
  8. /**
  9. * R01-N06: persistent (ip, email) throttle for /auth/local. Tests pin the
  10. * lock policy matrix and the window-rollover behaviour without sleeping —
  11. * the production code accepts an injected $now for exactly this reason.
  12. */
  13. final class AuthThrottleRepositoryTest extends TestCase
  14. {
  15. private static function at(string $iso): DateTimeImmutable
  16. {
  17. return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
  18. }
  19. public function testNoLockoutWhenRowMissing(): void
  20. {
  21. $repo = new AuthThrottleRepository($this->makeDb());
  22. $this->assertNull(
  23. $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:00:00Z'))
  24. );
  25. }
  26. public function testFirstFourFailuresDoNotLock(): void
  27. {
  28. $repo = new AuthThrottleRepository($this->makeDb());
  29. $now = self::at('2026-05-07T10:00:00Z');
  30. for ($i = 1; $i <= 4; $i++) {
  31. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  32. $this->assertNull($lock, "attempt {$i} must not lock");
  33. }
  34. $this->assertNull(
  35. $repo->lockoutFor('1.2.3.4', 'admin@example.com', $now)
  36. );
  37. }
  38. public function testFifthFailureLocksForFiveMinutes(): void
  39. {
  40. $repo = new AuthThrottleRepository($this->makeDb());
  41. $now = self::at('2026-05-07T10:00:00Z');
  42. for ($i = 1; $i <= 4; $i++) {
  43. $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  44. }
  45. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  46. $this->assertNotNull($lock);
  47. $this->assertSame(
  48. '2026-05-07T10:05:00Z',
  49. $lock->format('Y-m-d\TH:i:s\Z')
  50. );
  51. }
  52. public function testTenthFailureEscalatesToThirtyMinutes(): void
  53. {
  54. $repo = new AuthThrottleRepository($this->makeDb());
  55. $now = self::at('2026-05-07T10:00:00Z');
  56. for ($i = 1; $i <= 9; $i++) {
  57. $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  58. }
  59. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  60. $this->assertNotNull($lock);
  61. $this->assertSame(
  62. '2026-05-07T10:30:00Z',
  63. $lock->format('Y-m-d\TH:i:s\Z')
  64. );
  65. }
  66. public function testTwentiethFailureEscalatesToOneHour(): void
  67. {
  68. $repo = new AuthThrottleRepository($this->makeDb());
  69. $now = self::at('2026-05-07T10:00:00Z');
  70. for ($i = 1; $i <= 19; $i++) {
  71. $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  72. }
  73. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  74. $this->assertNotNull($lock);
  75. $this->assertSame(
  76. '2026-05-07T11:00:00Z',
  77. $lock->format('Y-m-d\TH:i:s\Z')
  78. );
  79. }
  80. public function testLockoutExpiresAfterDuration(): void
  81. {
  82. $repo = new AuthThrottleRepository($this->makeDb());
  83. $start = self::at('2026-05-07T10:00:00Z');
  84. for ($i = 1; $i <= 5; $i++) {
  85. $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
  86. }
  87. // Locked at +0..+4:59
  88. $this->assertNotNull(
  89. $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:04:59Z'))
  90. );
  91. // Unlocked at +5:00 (lock_until is exclusive: until > now ⇒ locked).
  92. $this->assertNull(
  93. $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:05:00Z'))
  94. );
  95. }
  96. public function testCounterResetsAfterIdleWindow(): void
  97. {
  98. $repo = new AuthThrottleRepository($this->makeDb());
  99. $start = self::at('2026-05-07T10:00:00Z');
  100. for ($i = 1; $i <= 4; $i++) {
  101. $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
  102. }
  103. // 16 minutes later → window is stale (15-min idle threshold).
  104. $afterIdle = self::at('2026-05-07T10:16:00Z');
  105. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $afterIdle);
  106. $this->assertNull($lock, 'counter must reset to 1 after idle window');
  107. }
  108. public function testCounterDoesNotResetWithinWindow(): void
  109. {
  110. $repo = new AuthThrottleRepository($this->makeDb());
  111. $start = self::at('2026-05-07T10:00:00Z');
  112. for ($i = 1; $i <= 4; $i++) {
  113. $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
  114. }
  115. // 14 minutes later → still inside the window.
  116. $stillInside = self::at('2026-05-07T10:14:00Z');
  117. $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $stillInside);
  118. $this->assertNotNull($lock, 'fifth attempt within the window must lock');
  119. }
  120. public function testClearRemovesRow(): void
  121. {
  122. $repo = new AuthThrottleRepository($this->makeDb());
  123. $now = self::at('2026-05-07T10:00:00Z');
  124. for ($i = 1; $i <= 5; $i++) {
  125. $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  126. }
  127. $this->assertNotNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now));
  128. $repo->clear('1.2.3.4', 'admin@example.com');
  129. $this->assertNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now));
  130. $next = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  131. $this->assertNull($next, 'after clear the next single failure must not lock');
  132. }
  133. public function testKeysAreCaseInsensitiveOnEmail(): void
  134. {
  135. // An attacker varying the case of the email shouldn't get extra
  136. // attempts (`Admin@…` vs `admin@…`); the bucket is canonicalised.
  137. $repo = new AuthThrottleRepository($this->makeDb());
  138. $now = self::at('2026-05-07T10:00:00Z');
  139. for ($i = 1; $i <= 4; $i++) {
  140. $repo->recordFailure('1.2.3.4', 'Admin@example.com', $now);
  141. }
  142. $lock = $repo->recordFailure('1.2.3.4', 'admin@EXAMPLE.com', $now);
  143. $this->assertNotNull($lock, 'case-varied email must hit the same bucket');
  144. }
  145. public function testIpsBucketSeparately(): void
  146. {
  147. $repo = new AuthThrottleRepository($this->makeDb());
  148. $now = self::at('2026-05-07T10:00:00Z');
  149. for ($i = 1; $i <= 5; $i++) {
  150. $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
  151. }
  152. // Different IP → not throttled even though the same email is
  153. // currently locked from another source.
  154. $this->assertNull(
  155. $repo->lockoutFor('5.6.7.8', 'admin@example.com', $now),
  156. 'a second attacker IP must not inherit the first IP\'s lock'
  157. );
  158. }
  159. public function testComputeLockoutPolicyMatrix(): void
  160. {
  161. $now = self::at('2026-05-07T10:00:00Z');
  162. $this->assertNull(AuthThrottleRepository::computeLockout(0, $now));
  163. $this->assertNull(AuthThrottleRepository::computeLockout(4, $now));
  164. $r5 = AuthThrottleRepository::computeLockout(5, $now);
  165. $this->assertNotNull($r5);
  166. $this->assertSame('2026-05-07T10:05:00Z', $r5->format('Y-m-d\TH:i:s\Z'));
  167. $r9 = AuthThrottleRepository::computeLockout(9, $now);
  168. $this->assertNotNull($r9);
  169. $this->assertSame('2026-05-07T10:05:00Z', $r9->format('Y-m-d\TH:i:s\Z'));
  170. $r10 = AuthThrottleRepository::computeLockout(10, $now);
  171. $this->assertNotNull($r10);
  172. $this->assertSame('2026-05-07T10:30:00Z', $r10->format('Y-m-d\TH:i:s\Z'));
  173. $r19 = AuthThrottleRepository::computeLockout(19, $now);
  174. $this->assertNotNull($r19);
  175. $this->assertSame('2026-05-07T10:30:00Z', $r19->format('Y-m-d\TH:i:s\Z'));
  176. $r20 = AuthThrottleRepository::computeLockout(20, $now);
  177. $this->assertNotNull($r20);
  178. $this->assertSame('2026-05-07T11:00:00Z', $r20->format('Y-m-d\TH:i:s\Z'));
  179. $r99 = AuthThrottleRepository::computeLockout(99, $now);
  180. $this->assertNotNull($r99);
  181. $this->assertSame('2026-05-07T11:00:00Z', $r99->format('Y-m-d\TH:i:s\Z'));
  182. }
  183. public function testPurgeExpiredDropsOnlyStaleRows(): void
  184. {
  185. $repo = new AuthThrottleRepository($this->makeDb());
  186. $repo->recordFailure('1.2.3.4', 'a@x', self::at('2026-05-07T08:00:00Z'));
  187. $repo->recordFailure('1.2.3.4', 'b@x', self::at('2026-05-07T11:00:00Z'));
  188. // Cutoff at 10:00 — 'a@x' (08:00) is older, 'b@x' (11:00) is fresh.
  189. $deleted = $repo->purgeExpired(self::at('2026-05-07T10:00:00Z'));
  190. $this->assertSame(1, $deleted);
  191. $this->assertNull(
  192. $repo->lockoutFor('1.2.3.4', 'a@x', self::at('2026-05-07T11:30:00Z'))
  193. );
  194. }
  195. }