1
0

RateLimitTest.php 3.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Public;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Time\Clock;
  7. use App\Domain\Time\FixedClock;
  8. use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
  9. use App\Infrastructure\Http\RateLimiter;
  10. use App\Tests\Integration\Support\AppTestCase;
  11. use Psr\Http\Message\ResponseFactoryInterface;
  12. /**
  13. * Override the AppTestCase to inject a tight rate limit and a fixed clock,
  14. * then run a burst that exceeds capacity (rate × 2).
  15. */
  16. final class RateLimitTest extends AppTestCase
  17. {
  18. protected function setUp(): void
  19. {
  20. parent::setUp();
  21. // Replace the clock + limiter with a tight, fixed-time pair: refill=2/s,
  22. // capacity=4. A burst of 20 must produce some 429s. We must also rebuild
  23. // the RateLimitMiddleware singleton — PHP-DI caches the constructed
  24. // instance, which already holds the wide-open limiter from setup.
  25. if (method_exists($this->container, 'set')) {
  26. /** @var \DI\Container $c */
  27. $c = $this->container;
  28. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  29. $limiter = new RateLimiter($clock, 2.0, 4.0);
  30. $c->set(Clock::class, $clock);
  31. $c->set(RateLimiter::class, $limiter);
  32. /** @var ResponseFactoryInterface $rf */
  33. $rf = $c->get(ResponseFactoryInterface::class);
  34. $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf));
  35. $this->app = \App\App\AppFactory::build($this->container);
  36. }
  37. }
  38. public function testBurstExceedingCapacityProduces429s(): void
  39. {
  40. $reporterId = $this->createReporter('web-limited');
  41. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  42. $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
  43. $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
  44. $statuses = [];
  45. for ($i = 0; $i < 20; $i++) {
  46. $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode();
  47. }
  48. $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202));
  49. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  50. // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles.
  51. self::assertSame(4, $accepted, 'capacity-bounded successes');
  52. self::assertSame(16, $limited, 'remainder rate-limited');
  53. }
  54. public function testRateLimit429IncludesRetryAfter(): void
  55. {
  56. $reporterId = $this->createReporter('web-retry');
  57. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  58. $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
  59. $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
  60. // Drain capacity first.
  61. for ($i = 0; $i < 4; $i++) {
  62. $this->request('POST', '/api/v1/report', $headers, $body);
  63. }
  64. $resp = $this->request('POST', '/api/v1/report', $headers, $body);
  65. self::assertSame(429, $resp->getStatusCode());
  66. self::assertSame('1', $resp->getHeaderLine('Retry-After'));
  67. }
  68. public function testAdminRoutesNotRateLimited(): void
  69. {
  70. $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
  71. // Admin routes should never 429 even when smashed.
  72. for ($i = 0; $i < 50; $i++) {
  73. $resp = $this->request('GET', '/api/v1/admin/me', [
  74. 'Authorization' => 'Bearer ' . $admin,
  75. ]);
  76. self::assertNotSame(429, $resp->getStatusCode());
  77. }
  78. }
  79. }