From 035711cd03a7642c49b55886d02590f147b0efeb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 12 May 2026 21:55:41 +0400 Subject: [PATCH 1/4] [#190] Add shared rate limit config and API feature tests --- shared/config/rate_limit.php | 27 +++++++ tests/Feature/modules/Api/RateLimitTest.php | 85 +++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 shared/config/rate_limit.php create mode 100644 tests/Feature/modules/Api/RateLimitTest.php 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/modules/Api/RateLimitTest.php b/tests/Feature/modules/Api/RateLimitTest.php new file mode 100644 index 0000000..2382909 --- /dev/null +++ b/tests/Feature/modules/Api/RateLimitTest.php @@ -0,0 +1,85 @@ +ensureRateLimitStorageExists(); + $this->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->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); + } + } + + private function ensureRateLimitStorageExists(): void + { + $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; + + if (!is_dir($rateLimitDir)) { + mkdir($rateLimitDir, 0777, true); + } + } +} From 6f393ef6df48affb324f87de14c7fe93fa452f69 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 12 May 2026 22:07:10 +0400 Subject: [PATCH 2/4] [#190] Add feature test rate-limit storage cleanup and local path repo config --- tests/Feature/AppTestCase.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Feature/AppTestCase.php b/tests/Feature/AppTestCase.php index 2285e1e..658d7e9 100644 --- a/tests/Feature/AppTestCase.php +++ b/tests/Feature/AppTestCase.php @@ -25,10 +25,12 @@ public function setUp(): void ob_start(); self::$app = createApp(AppType::WEB, PROJECT_ROOT); + $this->clearRateLimitStorage(); } public function tearDown(): void { + $this->clearRateLimitStorage(); parent::tearDown(); ob_end_clean(); } @@ -54,4 +56,21 @@ 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) { + @unlink($file); + } + + foreach (glob($rateLimitDir . DS . '*.lock') ?: [] as $file) { + @unlink($file); + } + } } From e5e62007f6998d8a0036df694a33d5011e2ebc9e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 13 May 2026 19:37:42 +0400 Subject: [PATCH 3/4] [#190] Tighten rate limit test setup and assertions --- tests/Feature/AppTestCase.php | 9 +++++-- tests/Feature/modules/Api/RateLimitTest.php | 4 ++- tests/_root/shared/config/rate_limit.php | 27 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/_root/shared/config/rate_limit.php diff --git a/tests/Feature/AppTestCase.php b/tests/Feature/AppTestCase.php index 658d7e9..c9c39c5 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 { @@ -66,11 +67,15 @@ private function clearRateLimitStorage(): void } foreach (glob($rateLimitDir . DS . '*.rate') ?: [] as $file) { - @unlink($file); + if (!unlink($file)) { + throw new RuntimeException('Failed to remove rate limit file: ' . $file); + } } foreach (glob($rateLimitDir . DS . '*.lock') ?: [] as $file) { - @unlink($file); + if (!unlink($file)) { + throw new RuntimeException('Failed to remove rate limit file: ' . $file); + } } } } diff --git a/tests/Feature/modules/Api/RateLimitTest.php b/tests/Feature/modules/Api/RateLimitTest.php index 2382909..0fabd17 100644 --- a/tests/Feature/modules/Api/RateLimitTest.php +++ b/tests/Feature/modules/Api/RateLimitTest.php @@ -48,6 +48,7 @@ public function testRateLimitBucketIsRouteSpecific(): void response()->flush(); $postsResponse = $this->request('get', '/api/en/posts'); + $this->assertSame('success', $postsResponse->get('status')); $this->assertNotSame(429, $postsResponse->getStatusCode()); } @@ -79,7 +80,8 @@ private function ensureRateLimitStorageExists(): void $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; if (!is_dir($rateLimitDir)) { - mkdir($rateLimitDir, 0777, true); + $created = mkdir($rateLimitDir, 0777, true); + $this->assertTrue($created || is_dir($rateLimitDir), 'Failed to create rate limit storage directory: ' . $rateLimitDir); } } } 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, + ], +]; From b5f8ae6f004d2bc044036277f0bd9439c7380d4f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 13 May 2026 20:09:27 +0400 Subject: [PATCH 4/4] [#190] Stabilize rate-limit test storage setup --- tests/Feature/AppTestCase.php | 16 ++++++++++++++++ tests/Feature/modules/Api/RateLimitTest.php | 10 ---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/Feature/AppTestCase.php b/tests/Feature/AppTestCase.php index c9c39c5..f902733 100644 --- a/tests/Feature/AppTestCase.php +++ b/tests/Feature/AppTestCase.php @@ -25,6 +25,7 @@ public function setUp(): void parent::setUp(); ob_start(); + $this->ensureRateLimitStorageExists(); self::$app = createApp(AppType::WEB, PROJECT_ROOT); $this->clearRateLimitStorage(); } @@ -78,4 +79,19 @@ private function clearRateLimitStorage(): void } } } + + 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 index 0fabd17..b04670c 100644 --- a/tests/Feature/modules/Api/RateLimitTest.php +++ b/tests/Feature/modules/Api/RateLimitTest.php @@ -10,7 +10,6 @@ class RateLimitTest extends AppTestCase public function setUp(): void { parent::setUp(); - $this->ensureRateLimitStorageExists(); $this->clearRateLimitStorage(); } @@ -75,13 +74,4 @@ private function clearRateLimitStorage(): void } } - private function ensureRateLimitStorageExists(): void - { - $rateLimitDir = PROJECT_ROOT . DS . 'cache' . DS . 'data'; - - if (!is_dir($rateLimitDir)) { - $created = mkdir($rateLimitDir, 0777, true); - $this->assertTrue($created || is_dir($rateLimitDir), 'Failed to create rate limit storage directory: ' . $rateLimitDir); - } - } }