| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Http;
- use App\Domain\Auth\AuthenticatedPrincipal;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Time\FixedClock;
- use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
- use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
- use App\Infrastructure\Http\RateLimiter;
- use PHPUnit\Framework\TestCase;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- use Psr\Http\Server\RequestHandlerInterface;
- use Slim\Psr7\Factory\ResponseFactory;
- use Slim\Psr7\Factory\ServerRequestFactory;
- /**
- * SEC_REVIEW F27. Two invariants:
- *
- * 1. With a principal: bucket key is `token:<tokenId>`, no IP draw.
- * 2. Without a principal: bucket key falls back to `ip:<REMOTE_ADDR>`
- * so 401 paths and pre-auth flooding are throttled instead of
- * bypassing the limiter.
- *
- * The `token:` and `ip:` namespaces draw on independent buckets — see
- * {@see RateLimiterTest::testTokenAndIpNamespacesDoNotShareABucket}.
- */
- final class RateLimitMiddlewareTest extends TestCase
- {
- public function testAuthenticatedRequestKeysOnTokenId(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- // capacity=2, refill=1/s. Token 42 should drain in two requests.
- $limiter = new RateLimiter($clock, 1.0, 2.0);
- $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
- $principal = new AuthenticatedPrincipal(
- tokenKind: TokenKind::Reporter,
- userId: null,
- role: null,
- reporterId: 1,
- consumerId: null,
- tokenId: 42,
- );
- $request = $this->request('1.2.3.4')
- ->withAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL, $principal);
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
- // Confirm the bucket actually was `token:42` — a different IP-only
- // request still has a full ip-bucket and must succeed.
- $unauth = $this->request('1.2.3.4');
- self::assertSame(200, $middleware->process($unauth, $this->okHandler())->getStatusCode());
- }
- public function testUnauthenticatedRequestKeysOnRemoteAddr(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, 1.0, 2.0);
- $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
- $request = $this->request('1.2.3.4');
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- // Third request from same IP, still no principal — bucket is drained.
- $third = $middleware->process($request, $this->okHandler());
- self::assertSame(429, $third->getStatusCode());
- self::assertSame('1', $third->getHeaderLine('Retry-After'));
- self::assertSame('application/json', $third->getHeaderLine('Content-Type'));
- self::assertStringContainsString('rate_limited', (string) $third->getBody());
- }
- public function testDifferentRemoteIpsHaveIndependentBuckets(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, 1.0, 2.0);
- $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
- // Drain 1.2.3.4.
- $a = $this->request('1.2.3.4');
- $middleware->process($a, $this->okHandler());
- $middleware->process($a, $this->okHandler());
- self::assertSame(429, $middleware->process($a, $this->okHandler())->getStatusCode());
- // 5.6.7.8 is unaffected.
- $b = $this->request('5.6.7.8');
- self::assertSame(200, $middleware->process($b, $this->okHandler())->getStatusCode());
- self::assertSame(200, $middleware->process($b, $this->okHandler())->getStatusCode());
- }
- public function testMissingRemoteAddrFallsBackToUnknownBucket(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, 1.0, 2.0);
- $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
- $request = (new ServerRequestFactory())->createServerRequest('GET', 'https://api.test/x');
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
- }
- public function testEmptyRemoteAddrFallsBackToUnknownBucket(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, 1.0, 2.0);
- $middleware = new RateLimitMiddleware($limiter, new ResponseFactory());
- $request = $this->request('');
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(200, $middleware->process($request, $this->okHandler())->getStatusCode());
- self::assertSame(429, $middleware->process($request, $this->okHandler())->getStatusCode());
- }
- private function request(string $remoteAddr): ServerRequestInterface
- {
- return (new ServerRequestFactory())
- ->createServerRequest('GET', 'https://api.test/x', ['REMOTE_ADDR' => $remoteAddr]);
- }
- private function okHandler(): RequestHandlerInterface
- {
- return new class () implements RequestHandlerInterface {
- public function handle(ServerRequestInterface $request): ResponseInterface
- {
- return (new ResponseFactory())->createResponse(200);
- }
- };
- }
- }
|