1
0

RateLimiterTest.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Domain\Time\FixedClock;
  5. use App\Infrastructure\Http\RateLimiter;
  6. use PHPUnit\Framework\TestCase;
  7. /**
  8. * Token-bucket invariants:
  9. * - capacity = 2 × refill, so 6 immediate consumes succeed at refill=3.
  10. * - the 7th fails, then advancing 1s restores 3 tokens.
  11. * - per-key isolation: two keys do not share a bucket.
  12. * - keys are arbitrary strings so callers can namespace tokens vs IPs
  13. * (SEC_REVIEW F27).
  14. */
  15. final class RateLimiterTest extends TestCase
  16. {
  17. public function testBurstUpToCapacitySucceeds(): void
  18. {
  19. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  20. $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
  21. for ($i = 0; $i < 6; $i++) {
  22. self::assertTrue($limiter->tryConsume('token:1'), "consume #{$i} should succeed");
  23. }
  24. self::assertFalse($limiter->tryConsume('token:1'), 'consume #7 should be denied');
  25. }
  26. public function testRefillRestoresTokensOverTime(): void
  27. {
  28. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  29. $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
  30. for ($i = 0; $i < 6; $i++) {
  31. $limiter->tryConsume('token:1');
  32. }
  33. self::assertFalse($limiter->tryConsume('token:1'));
  34. $clock->advance('+1 second');
  35. // After 1s @ 3/s we should have 3 fresh tokens.
  36. self::assertTrue($limiter->tryConsume('token:1'));
  37. self::assertTrue($limiter->tryConsume('token:1'));
  38. self::assertTrue($limiter->tryConsume('token:1'));
  39. self::assertFalse($limiter->tryConsume('token:1'));
  40. }
  41. public function testKeysAreIsolated(): void
  42. {
  43. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  44. $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);
  45. self::assertTrue($limiter->tryConsume('token:1'));
  46. self::assertTrue($limiter->tryConsume('token:1'));
  47. self::assertFalse($limiter->tryConsume('token:1'));
  48. // Token 2's bucket is untouched.
  49. self::assertTrue($limiter->tryConsume('token:2'));
  50. self::assertTrue($limiter->tryConsume('token:2'));
  51. }
  52. public function testTokenAndIpNamespacesDoNotShareABucket(): void
  53. {
  54. // SEC_REVIEW F27: namespace-prefixed keys mean an authenticated
  55. // user's `token:42` bucket cannot drain the same shared `ip:…`
  56. // bucket that throttles unauthenticated traffic from elsewhere.
  57. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  58. $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);
  59. self::assertTrue($limiter->tryConsume('token:42'));
  60. self::assertTrue($limiter->tryConsume('token:42'));
  61. self::assertFalse($limiter->tryConsume('token:42'));
  62. // Same numeric tail under a different namespace must be its own
  63. // bucket — string keys are not silently coerced to int.
  64. self::assertTrue($limiter->tryConsume('ip:42'));
  65. self::assertTrue($limiter->tryConsume('ip:42'));
  66. self::assertFalse($limiter->tryConsume('ip:42'));
  67. }
  68. /**
  69. * SEC_REVIEW F28: the bucket map must not grow without bound. When
  70. * the cap is exceeded, the oldest entries (LRU front of the PHP
  71. * insertion-ordered array) get dropped in a batch.
  72. */
  73. public function testBucketMapIsBoundedByMaxBucketsCap(): void
  74. {
  75. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  76. // Cap at 300 (must exceed the 256-entry eviction batch).
  77. $limiter = new RateLimiter(
  78. $clock,
  79. refillPerSecond: 1.0,
  80. capacity: 2.0,
  81. maxBuckets: 300,
  82. );
  83. // Touch 600 distinct keys — well past the cap.
  84. for ($i = 0; $i < 600; $i++) {
  85. $limiter->tryConsume("ip:10.0.0.{$i}");
  86. }
  87. self::assertLessThanOrEqual(
  88. 300,
  89. $limiter->bucketCount(),
  90. 'bucket count must stay within the configured cap'
  91. );
  92. }
  93. /**
  94. * SEC_REVIEW F28: an evicted bucket comes back as a fresh full-capacity
  95. * bucket on its next consume — equivalent to the bucket having idled
  96. * long enough to refill ($capacity / $refill seconds), so eviction
  97. * never gives an attacker more tokens than the rate would already
  98. * permit. This test asserts the behaviour explicitly so a future
  99. * "make eviction smarter" change cannot regress it into something
  100. * stricter (and break the documented refill semantics) or looser
  101. * (and grant tokens beyond the documented refill).
  102. */
  103. public function testEvictedBucketReturnsAtFullCapacityOnReuse(): void
  104. {
  105. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  106. $limiter = new RateLimiter(
  107. $clock,
  108. refillPerSecond: 1.0,
  109. capacity: 2.0,
  110. maxBuckets: 300,
  111. );
  112. // Drain key=A.
  113. self::assertTrue($limiter->tryConsume('ip:A'));
  114. self::assertTrue($limiter->tryConsume('ip:A'));
  115. self::assertFalse($limiter->tryConsume('ip:A'));
  116. // Force eviction by inserting many fresh keys past the cap.
  117. for ($i = 0; $i < 600; $i++) {
  118. $limiter->tryConsume("ip:fan-{$i}");
  119. }
  120. // Same wall-clock time — without eviction, A would still be drained.
  121. // With eviction, A is gone and a re-touch starts a fresh full bucket.
  122. self::assertTrue($limiter->tryConsume('ip:A'));
  123. self::assertTrue($limiter->tryConsume('ip:A'));
  124. self::assertFalse($limiter->tryConsume('ip:A'));
  125. }
  126. /**
  127. * SEC_REVIEW F28: re-touching a key moves it to the end of the PHP
  128. * insertion-ordered array, so a hot key is the *last* candidate for
  129. * eviction even under heavy churn from other keys. Asserting this
  130. * keeps a regression that broke the LRU ordering from silently
  131. * letting an attacker reset their own bucket by smashing many
  132. * unrelated keys.
  133. */
  134. public function testRecentlyTouchedKeyIsNotEvicted(): void
  135. {
  136. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  137. $limiter = new RateLimiter(
  138. $clock,
  139. refillPerSecond: 1.0,
  140. capacity: 2.0,
  141. maxBuckets: 300,
  142. );
  143. // Drain ip:hot.
  144. self::assertTrue($limiter->tryConsume('ip:hot'));
  145. self::assertTrue($limiter->tryConsume('ip:hot'));
  146. self::assertFalse($limiter->tryConsume('ip:hot'));
  147. // Heavy fan-out from other keys, but keep re-touching hot so it
  148. // stays at the LRU tail.
  149. for ($i = 0; $i < 600; $i++) {
  150. $limiter->tryConsume("ip:churn-{$i}");
  151. $limiter->tryConsume('ip:hot'); // returns false; bucket stays drained
  152. }
  153. // hot still drained — eviction picked the cold churn keys, not hot.
  154. self::assertFalse($limiter->tryConsume('ip:hot'));
  155. }
  156. public function testMaxBucketsBelowEvictionBatchIsRejected(): void
  157. {
  158. // A pathologically small cap would evict the entry we just
  159. // inserted; refuse to construct.
  160. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  161. $this->expectException(\InvalidArgumentException::class);
  162. new RateLimiter(
  163. $clock,
  164. refillPerSecond: 1.0,
  165. capacity: 2.0,
  166. maxBuckets: 10,
  167. );
  168. }
  169. }