`, no IP draw. * 2. Without a principal: bucket key falls back to `ip:` * 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); } }; } }