| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Public;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Time\Clock;
- use App\Domain\Time\FixedClock;
- use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
- use App\Infrastructure\Http\RateLimiter;
- use App\Tests\Integration\Support\AppTestCase;
- use Psr\Http\Message\ResponseFactoryInterface;
- /**
- * Override the AppTestCase to inject a tight rate limit and a fixed clock,
- * then run a burst that exceeds capacity (rate × 2).
- */
- final class RateLimitTest extends AppTestCase
- {
- protected function setUp(): void
- {
- parent::setUp();
- // Replace the clock + limiter with a tight, fixed-time pair: refill=2/s,
- // capacity=4. A burst of 20 must produce some 429s. We must also rebuild
- // the RateLimitMiddleware singleton — PHP-DI caches the constructed
- // instance, which already holds the wide-open limiter from setup.
- if (method_exists($this->container, 'set')) {
- /** @var \DI\Container $c */
- $c = $this->container;
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, 2.0, 4.0);
- $c->set(Clock::class, $clock);
- $c->set(RateLimiter::class, $limiter);
- /** @var ResponseFactoryInterface $rf */
- $rf = $c->get(ResponseFactoryInterface::class);
- $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf));
- $this->app = \App\App\AppFactory::build($this->container);
- }
- }
- public function testBurstExceedingCapacityProduces429s(): void
- {
- $reporterId = $this->createReporter('web-limited');
- $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
- $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
- $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
- $statuses = [];
- for ($i = 0; $i < 20; $i++) {
- $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode();
- }
- $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202));
- $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
- // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles.
- self::assertSame(4, $accepted, 'capacity-bounded successes');
- self::assertSame(16, $limited, 'remainder rate-limited');
- }
- public function testRateLimit429IncludesRetryAfter(): void
- {
- $reporterId = $this->createReporter('web-retry');
- $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
- $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
- $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
- // Drain capacity first.
- for ($i = 0; $i < 4; $i++) {
- $this->request('POST', '/api/v1/report', $headers, $body);
- }
- $resp = $this->request('POST', '/api/v1/report', $headers, $body);
- self::assertSame(429, $resp->getStatusCode());
- self::assertSame('1', $resp->getHeaderLine('Retry-After'));
- }
- public function testAdminRoutesNotRateLimited(): void
- {
- $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
- // Admin routes should never 429 even when smashed.
- for ($i = 0; $i < 50; $i++) {
- $resp = $this->request('GET', '/api/v1/admin/me', [
- 'Authorization' => 'Bearer ' . $admin,
- ]);
- self::assertNotSame(429, $resp->getStatusCode());
- }
- }
- /**
- * SEC_REVIEW F14. The /api/v1/auth/* group previously had only
- * TokenAuth, so a service-token holder could brute-force enumerate
- * users via /users/{id} (F17) at unbounded rate, or burn unbounded
- * upsert calls. RateLimitMiddleware is now attached; a burst against
- * the auth endpoints must produce 429s once the bucket drains.
- */
- public function testAuthGetUserRouteIsRateLimited(): void
- {
- $service = $this->createToken(TokenKind::Service);
- $headers = ['Authorization' => 'Bearer ' . $service];
- $statuses = [];
- for ($i = 0; $i < 20; $i++) {
- $statuses[] = $this->request('GET', '/api/v1/auth/users/1', $headers)->getStatusCode();
- }
- $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
- self::assertGreaterThan(
- 0,
- $limited,
- 'auth/users/{id} must hit the rate-limit ceiling on burst (SEC_REVIEW F14)'
- );
- // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429.
- self::assertSame(16, $limited, 'remainder rate-limited');
- }
- /**
- * SEC_REVIEW F14. upsertLocal/upsertOidc are also gated by the same
- * rate limit — a leaked service token cannot pound them at unbounded
- * rate to amplify other findings (e.g. burning audit rows, retrying
- * upsert combinations).
- */
- public function testAuthUpsertLocalRouteIsRateLimited(): void
- {
- $service = $this->createToken(TokenKind::Service);
- $headers = [
- 'Authorization' => 'Bearer ' . $service,
- 'Content-Type' => 'application/json',
- ];
- $body = json_encode(['username' => 'admin']) ?: null;
- $statuses = [];
- for ($i = 0; $i < 20; $i++) {
- $statuses[] = $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body)
- ->getStatusCode();
- }
- $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
- self::assertGreaterThan(0, $limited, 'upsert-local must rate-limit');
- }
- /**
- * SEC_REVIEW F27. Auth-failed paths (401 from
- * TokenAuthenticationMiddleware) used to bypass the rate limiter
- * because RateLimitMiddleware sat *inside* TokenAuth. The handler
- * now also runs as the outermost layer, keying on `ip:` when no
- * principal exists, so invalid-bearer-token floods are throttled
- * before the DB lookup.
- */
- public function testInvalidBearerTokenFloodIsRateLimitedBeforeAuth(): void
- {
- $headers = ['Authorization' => 'Bearer ' . str_repeat('a', 32)];
- $statuses = [];
- for ($i = 0; $i < 20; $i++) {
- $statuses[] = $this->request('POST', '/api/v1/report', $headers)->getStatusCode();
- }
- $unauthorized = count(array_filter($statuses, static fn (int $s): bool => $s === 401));
- $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
- // Capacity is 4, so the first 4 attempts incur a token-table lookup
- // and return 401; the rest drain the IP bucket and return 429.
- self::assertSame(4, $unauthorized, 'first 4 attempts hit the auth path');
- self::assertSame(16, $limited, 'remaining attempts must 429 before auth');
- }
- public function testMissingBearerHeaderIsAlsoRateLimitedByIp(): void
- {
- // No Authorization header at all — same protection applies, the
- // request must not reach TokenAuth on every retry.
- $statuses = [];
- for ($i = 0; $i < 10; $i++) {
- $statuses[] = $this->request('GET', '/api/v1/blocklist')->getStatusCode();
- }
- $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
- self::assertGreaterThan(0, $limited, 'pre-auth flood must hit 429');
- }
- }
|