RateLimiterTest.php 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  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. }