1
0

AuthThrottleRepositoryTest.php 8.9 KB

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