diff --git a/shared/config/rate_limit.php b/shared/config/rate_limit.php new file mode 100644 index 0000000..5c4774f --- /dev/null +++ b/shared/config/rate_limit.php @@ -0,0 +1,27 @@ + env('RATE_LIMIT_ADAPTER', 'file'), + + /** + * --------------------------------------------------------- + * Rate limit connections + * --------------------------------------------------------- + */ + 'file' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'path' => base_dir() . DS . 'cache' . DS . 'data', + 'ttl' => 60, + ], + 'redis' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', 6379), + 'ttl' => 60, + ], +]; diff --git a/tests/Feature/AppTestCase.php b/tests/Feature/AppTestCase.php index 2285e1e..f902733 100644 --- a/tests/Feature/AppTestCase.php +++ b/tests/Feature/AppTestCase.php @@ -6,6 +6,7 @@ use Quantum\App\Enums\AppType; use Quantum\Http\Response; use Quantum\App\App; +use RuntimeException; class AppTestCase extends TestCase { @@ -24,11 +25,14 @@ public function setUp(): void parent::setUp(); ob_start(); + $this->ensureRateLimitStorageExists(); self::$app = createApp(AppType::WEB, PROJECT_ROOT); + $this->clearRateLimitStorage(); } public function tearDown(): void { + $this->clearRateLimitStorage(); parent::tearDown(); ob_end_clean(); } @@ -54,4 +58,40 @@ protected function signInAndGetTokens(): array return $response->get('tokens'); } + + private function clearRateLimitStorage(): void + { + $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; + + if (!is_dir($rateLimitDir)) { + return; + } + + foreach (glob($rateLimitDir . DS . '*.rate') ?: [] as $file) { + if (!unlink($file)) { + throw new RuntimeException('Failed to remove rate limit file: ' . $file); + } + } + + foreach (glob($rateLimitDir . DS . '*.lock') ?: [] as $file) { + if (!unlink($file)) { + throw new RuntimeException('Failed to remove rate limit file: ' . $file); + } + } + } + + private function ensureRateLimitStorageExists(): void + { + $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; + + if (is_dir($rateLimitDir)) { + return; + } + + $created = mkdir($rateLimitDir, 0777, true); + + if (!$created && !is_dir($rateLimitDir)) { + throw new RuntimeException('Failed to create rate limit storage directory: ' . $rateLimitDir); + } + } } diff --git a/tests/Feature/modules/Api/RateLimitTest.php b/tests/Feature/modules/Api/RateLimitTest.php new file mode 100644 index 0000000..b04670c --- /dev/null +++ b/tests/Feature/modules/Api/RateLimitTest.php @@ -0,0 +1,77 @@ +clearRateLimitStorage(); + } + + public function tearDown(): void + { + $this->clearRateLimitStorage(); + parent::tearDown(); + } + + public function testSignInEndpointReturns429WhenLimitExceeded(): void + { + for ($i = 0; $i < 10; $i++) { + $response = $this->attemptFailedSignin(); + $this->assertNotSame(429, $response->getStatusCode()); + } + + $blocked = $this->attemptFailedSignin(); + + $this->assertSame(429, $blocked->getStatusCode()); + $this->assertSame('10', $blocked->getHeader('X-RateLimit-Limit')); + $this->assertSame('0', $blocked->getHeader('X-RateLimit-Remaining')); + $this->assertNotNull($blocked->getHeader('Retry-After')); + $this->assertSame('Too Many Requests', $blocked->get('message')); + } + + public function testRateLimitBucketIsRouteSpecific(): void + { + for ($i = 0; $i < 10; $i++) { + $this->attemptFailedSignin(); + } + + $blocked = $this->attemptFailedSignin(); + $this->assertSame(429, $blocked->getStatusCode()); + + response()->flush(); + + $postsResponse = $this->request('get', '/api/en/posts'); + $this->assertSame('success', $postsResponse->get('status')); + $this->assertNotSame(429, $postsResponse->getStatusCode()); + } + + private function attemptFailedSignin(): Response + { + response()->flush(); + + return $this->request('post', '/api/en/signin', [ + 'email' => 'non-existing-user@example.com', + 'password' => 'invalid-password', + ]); + } + + private function clearRateLimitStorage(): void + { + $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; + + foreach (glob($rateLimitDir . DS . '*.rate') ?: [] as $file) { + @unlink($file); + } + + foreach (glob($rateLimitDir . DS . '*.lock') ?: [] as $file) { + @unlink($file); + } + } + +} diff --git a/tests/_root/shared/config/rate_limit.php b/tests/_root/shared/config/rate_limit.php new file mode 100644 index 0000000..5c4774f --- /dev/null +++ b/tests/_root/shared/config/rate_limit.php @@ -0,0 +1,27 @@ + env('RATE_LIMIT_ADAPTER', 'file'), + + /** + * --------------------------------------------------------- + * Rate limit connections + * --------------------------------------------------------- + */ + 'file' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'path' => base_dir() . DS . 'cache' . DS . 'data', + 'ttl' => 60, + ], + 'redis' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', 6379), + 'ttl' => 60, + ], +];