1
0

RateLimitMiddlewareTest.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Domain\Auth\AuthenticatedPrincipal;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Time\FixedClock;
  7. use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
  8. use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
  9. use App\Infrastructure\Http\RateLimiter;
  10. use PHPUnit\Framework\TestCase;
  11. use Psr\Http\Message\ResponseInterface;
  12. use Psr\Http\Message\ServerRequestInterface;
  13. use Psr\Http\Server\RequestHandlerInterface;
  14. use Slim\Psr7\Factory\ResponseFactory;
  15. use Slim\Psr7\Factory\ServerRequestFactory;
  16. /**
  17. * SEC_REVIEW F27. Two invariants:
  18. *
  19. * 1. With a principal: bucket key is `token:<tokenId>`, no IP draw.
  20. * 2. Without a principal: bucket key falls back to `ip:<REMOTE_ADDR>`
  21. * so 401 paths and pre-auth flooding are throttled instead of
  22. * bypassing the limiter.
  23. *
  24. * The `token:` and `ip:` namespaces draw on independent buckets — see
  25. * {@see RateLimiterTest::testTokenAndIpNamespacesDoNotShareABucket}.
  26. */
  27. final class RateLimitMiddlewareTest extends TestCase
  28. {
  29. public function testAuthenticatedRequestKeysOnTokenId(): void
  30. {
  31. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  32. // capacity=2, refill=1/s. Token 42 should drain in two requests.
  33. $limiter = new RateLimiter($clock, 1.0, 2.0);
  34. $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
  35. $principal = new AuthenticatedPrincipal(
  36. tokenKind: TokenKind::Reporter,
  37. userId: null,
  38. role: null,
  39. reporterId: 1,
  40. consumerId: null,
  41. tokenId: 42,
  42. );
  43. $request = $this->request('1.2.3.4')
  44. ->withAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL, $principal);
  45. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  46. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  47. self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
  48. // Confirm the bucket actually was `token:42` — a different IP-only
  49. // request still has a full ip-bucket and must succeed.
  50. $unauth = $this->request('1.2.3.4');
  51. self::assertSame(200, $middleware->process($unauth, $this->okHandler())->getStatusCode());
  52. }
  53. public function testUnauthenticatedRequestKeysOnRemoteAddr(): void
  54. {
  55. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  56. $limiter = new RateLimiter($clock, 1.0, 2.0);
  57. $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
  58. $request = $this->request('1.2.3.4');
  59. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  60. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  61. // Third request from same IP, still no principal — bucket is drained.
  62. $third = $middleware->process($request, $this->okHandler());
  63. self::assertSame(429, $third->getStatusCode());
  64. self::assertSame('1', $third->getHeaderLine('Retry-After'));
  65. self::assertSame('application/json', $third->getHeaderLine('Content-Type'));
  66. self::assertStringContainsString('rate_limited', (string) $third->getBody());
  67. }
  68. public function testDifferentRemoteIpsHaveIndependentBuckets(): void
  69. {
  70. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  71. $limiter = new RateLimiter($clock, 1.0, 2.0);
  72. $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
  73. // Drain 1.2.3.4.
  74. $a = $this->request('1.2.3.4');
  75. $middleware->process($a, $this->okHandler());
  76. $middleware->process($a, $this->okHandler());
  77. self::assertSame(429, $middleware->process($a, $this->okHandler())->getStatusCode());
  78. // 5.6.7.8 is unaffected.
  79. $b = $this->request('5.6.7.8');
  80. self::assertSame(200, $middleware->process($b, $this->okHandler())->getStatusCode());
  81. self::assertSame(200, $middleware->process($b, $this->okHandler())->getStatusCode());
  82. }
  83. public function testMissingRemoteAddrFallsBackToUnknownBucket(): void
  84. {
  85. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  86. $limiter = new RateLimiter($clock, 1.0, 2.0);
  87. $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
  88. $request = (new ServerRequestFactory())->createServerRequest('GET', 'https://api.test/x');
  89. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  90. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  91. self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
  92. }
  93. public function testEmptyRemoteAddrFallsBackToUnknownBucket(): void
  94. {
  95. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  96. $limiter = new RateLimiter($clock, 1.0, 2.0);
  97. $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
  98. $request = $this->request('');
  99. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  100. self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
  101. self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
  102. }
  103. private function request(string $remoteAddr): ServerRequestInterface
  104. {
  105. return (new ServerRequestFactory())
  106. ->createServerRequest('GET', 'https://api.test/x', ['REMOTE_ADDR' => $remoteAddr]);
  107. }
  108. private function okHandler(): RequestHandlerInterface
  109. {
  110. return new class () implements RequestHandlerInterface {
  111. public function handle(ServerRequestInterface $request): ResponseInterface
  112. {
  113. return (new ResponseFactory())->createResponse(200);
  114. }
  115. };
  116. }
  117. }