|
@@ -0,0 +1,140 @@
|
|
|
|
|
+<?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);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|