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()); } } }