225 lines
7.2 KiB
PHP
225 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Tests\Unit\Middleware;
|
|
|
|
use Grav\Plugin\Api\Middleware\RateLimitMiddleware;
|
|
use Grav\Plugin\Api\Tests\Unit\TestHelper;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Tests for the token-bucket RateLimitMiddleware.
|
|
*
|
|
* The real class calls Grav::instance()['locator'] in getStorageDir().
|
|
* We extend the class and override getStorageDir() to use a temp directory,
|
|
* making the core checkLimit() logic testable in isolation.
|
|
*/
|
|
#[CoversClass(RateLimitMiddleware::class)]
|
|
class RateLimitMiddlewareTest extends TestCase
|
|
{
|
|
private string $tempDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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;
|
|
}
|
|
};
|
|
}
|
|
}
|