tempDir = sys_get_temp_dir() . '/grav_api_ratelimit_test_' . uniqid(); @mkdir($this->tempDir, 0775, true); } protected function tearDown(): void { $files = glob($this->tempDir . '/*'); if ($files) { array_map('unlink', $files); } @rmdir($this->tempDir); } #[Test] public function check_returns_not_limited_when_under_limit(): void { $middleware = $this->createTestableMiddleware(limit: 10, window: 60); $request = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '127.0.0.1'], ); $result = $middleware->check($request); self::assertFalse($result['limited']); self::assertSame(10, $result['limit']); self::assertGreaterThanOrEqual(0, $result['remaining']); } #[Test] public function check_returns_limited_when_over_limit(): void { $middleware = $this->createTestableMiddleware(limit: 3, window: 60); $request = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '10.0.0.1'], ); // Exhaust the 3 allowed tokens for ($i = 0; $i < 3; $i++) { $result = $middleware->check($request); self::assertFalse($result['limited'], "Request $i should not be limited"); } // The 4th request should be rate-limited $result = $middleware->check($request); self::assertTrue($result['limited']); self::assertSame(0, $result['remaining']); } #[Test] public function rate_limit_disabled_always_allows(): void { $config = TestHelper::createMockConfig([ 'plugins' => ['api' => ['rate_limit' => [ 'enabled' => false, 'requests' => 5, 'window' => 60, ]]], ]); $tempDir = $this->tempDir; $middleware = new class ($config, $tempDir) extends RateLimitMiddleware { public function __construct( \Grav\Common\Config\Config $config, private readonly string $dir, ) { parent::__construct($config); } protected function getStorageDir(): string { return $this->dir; } }; $request = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '192.168.1.1'], ); // Even after many requests it should never be limited for ($i = 0; $i < 20; $i++) { $result = $middleware->check($request); self::assertFalse($result['limited']); self::assertSame(5, $result['remaining']); } } #[Test] public function tokens_refill_over_time(): void { $limit = 2; $window = 60; $middleware = $this->createTestableMiddleware(limit: $limit, window: $window); $request = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '172.16.0.1'], ); // Exhaust all tokens for ($i = 0; $i < $limit; $i++) { $middleware->check($request); } // Manipulate the stored file to simulate time passing. $identifier = 'ip:172.16.0.1'; $file = $this->tempDir . '/' . md5($identifier) . '.json'; self::assertFileExists($file); $data = json_decode(file_get_contents($file), true); // Pretend last_refill was 30 seconds ago. // With limit=2, window=60: refill rate = 2/60 per second. // After 30s: refill = 30 * (2/60) = 1 token. $data['last_refill'] -= 30; file_put_contents($file, json_encode($data)); $result = $middleware->check($request); self::assertFalse($result['limited'], 'Token bucket should have refilled after elapsed time'); } #[Test] public function different_users_have_separate_limits(): void { $middleware = $this->createTestableMiddleware(limit: 2, window: 60); $requestA = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '10.0.0.1'], ); $requestB = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '10.0.0.2'], ); // Exhaust limit for user A for ($i = 0; $i < 2; $i++) { $middleware->check($requestA); } $resultA = $middleware->check($requestA); self::assertTrue($resultA['limited'], 'User A should be rate-limited'); // User B should still have full budget $resultB = $middleware->check($requestB); self::assertFalse($resultB['limited'], 'User B should NOT be rate-limited'); } #[Test] public function authenticated_user_identified_by_username(): void { $middleware = $this->createTestableMiddleware(limit: 1, window: 60); $user = TestHelper::createMockUser('alice'); // Two different IPs but same authenticated user $requestFromOffice = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '10.0.0.1'], attributes: ['api_user' => $user], ); $requestFromHome = TestHelper::createMockRequest( serverParams: ['REMOTE_ADDR' => '192.168.1.1'], attributes: ['api_user' => $user], ); // First request uses the single token $result = $middleware->check($requestFromOffice); self::assertFalse($result['limited']); // Second request should also be limited (same user bucket) $result = $middleware->check($requestFromHome); self::assertTrue($result['limited']); } /** * Build a testable RateLimitMiddleware subclass that uses a temp storage directory. */ private function createTestableMiddleware(int $limit = 120, int $window = 60): RateLimitMiddleware { $config = TestHelper::createMockConfig([ 'plugins' => ['api' => ['rate_limit' => [ 'enabled' => true, 'requests' => $limit, 'window' => $window, ]]], ]); $tempDir = $this->tempDir; return new class ($config, $tempDir) extends RateLimitMiddleware { public function __construct( \Grav\Common\Config\Config $config, private readonly string $dir, ) { parent::__construct($config); } protected function getStorageDir(): string { return $this->dir; } }; } }