feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Tests\Unit\Middleware;
|
||||
|
||||
use Grav\Plugin\Api\Middleware\CorsMiddleware;
|
||||
use Grav\Plugin\Api\Tests\Unit\TestHelper;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
#[CoversClass(CorsMiddleware::class)]
|
||||
class CorsMiddlewareTest extends TestCase
|
||||
{
|
||||
private function buildMiddleware(array $corsConfig): CorsMiddleware
|
||||
{
|
||||
$config = TestHelper::createMockConfig([
|
||||
'plugins' => ['api' => ['cors' => $corsConfig]],
|
||||
]);
|
||||
|
||||
return new CorsMiddleware($config);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function adds_cors_headers_to_response(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
'credentials' => false,
|
||||
'expose_headers' => [],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://example.com']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('*', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wildcard_origin_allows_all(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://any-domain.test']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('*', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function specific_origin_matching(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['http://allowed.test', 'http://also-allowed.test'],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://allowed.test']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('http://allowed.test', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
self::assertSame('Origin', $result->getHeaderLine('Vary'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function non_matching_origin_no_cors_headers(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['http://allowed.test'],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://evil.test']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function credentials_header_when_enabled(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
'credentials' => true,
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://example.com']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('true', $result->getHeaderLine('Access-Control-Allow-Credentials'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cors_disabled_no_headers(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => false,
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://example.com']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function no_origin_header_no_cors_headers(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest();
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
self::assertSame('', $result->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expose_headers_are_set(): void
|
||||
{
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
'expose_headers' => ['X-Request-Id', 'X-Rate-Limit-Remaining'],
|
||||
]);
|
||||
|
||||
$request = TestHelper::createMockRequest(headers: ['Origin' => 'http://example.com']);
|
||||
$response = $this->createStubResponse();
|
||||
|
||||
$result = $middleware->addHeaders($request, $response);
|
||||
|
||||
// CorsMiddleware always appends X-Invalidates so clients can read
|
||||
// cache-invalidation tags, regardless of the configured expose_headers.
|
||||
self::assertSame(
|
||||
'X-Request-Id, X-Rate-Limit-Remaining, X-Invalidates',
|
||||
$result->getHeaderLine('Access-Control-Expose-Headers'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preflight_response_has_cors_headers(): void
|
||||
{
|
||||
$_SERVER['HTTP_ORIGIN'] = 'http://example.com';
|
||||
|
||||
try {
|
||||
$middleware = $this->buildMiddleware([
|
||||
'enabled' => true,
|
||||
'origins' => ['*'],
|
||||
'methods' => ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
'headers' => ['Authorization', 'Content-Type'],
|
||||
'max_age' => 86400,
|
||||
'credentials' => false,
|
||||
]);
|
||||
|
||||
$response = $middleware->createPreflightResponse();
|
||||
|
||||
self::assertInstanceOf(ResponseInterface::class, $response);
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
self::assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
|
||||
self::assertStringContainsString('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
|
||||
self::assertStringContainsString('Authorization', $response->getHeaderLine('Access-Control-Allow-Headers'));
|
||||
self::assertSame('86400', $response->getHeaderLine('Access-Control-Max-Age'));
|
||||
self::assertSame('0', $response->getHeaderLine('Content-Length'));
|
||||
} finally {
|
||||
unset($_SERVER['HTTP_ORIGIN']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight PSR-7 ResponseInterface stub with withHeader() support.
|
||||
*/
|
||||
private function createStubResponse(): ResponseInterface
|
||||
{
|
||||
return new \Grav\Framework\Psr7\Response();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Tests\Unit\Middleware;
|
||||
|
||||
use Grav\Plugin\Api\Exceptions\ValidationException;
|
||||
use Grav\Plugin\Api\Middleware\JsonBodyParserMiddleware;
|
||||
use Grav\Plugin\Api\Tests\Unit\TestHelper;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
#[CoversClass(JsonBodyParserMiddleware::class)]
|
||||
class JsonBodyParserMiddlewareTest extends TestCase
|
||||
{
|
||||
private JsonBodyParserMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new JsonBodyParserMiddleware();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parses_json_body(): void
|
||||
{
|
||||
$payload = ['title' => 'Hello', 'published' => true];
|
||||
$request = TestHelper::createMockRequest(
|
||||
method: 'POST',
|
||||
headers: ['Content-Type' => 'application/json'],
|
||||
body: json_encode($payload),
|
||||
);
|
||||
|
||||
$result = $this->middleware->processRequest($request);
|
||||
|
||||
self::assertInstanceOf(ServerRequestInterface::class, $result);
|
||||
self::assertSame($payload, $result->getAttribute('json_body'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignores_non_json_content_type(): void
|
||||
{
|
||||
$request = TestHelper::createMockRequest(
|
||||
method: 'POST',
|
||||
headers: ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
body: 'foo=bar',
|
||||
);
|
||||
|
||||
$result = $this->middleware->processRequest($request);
|
||||
|
||||
// The request should be returned as-is (no json_body attribute set)
|
||||
self::assertNull($result->getAttribute('json_body'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function empty_body_returns_empty_array(): void
|
||||
{
|
||||
$request = TestHelper::createMockRequest(
|
||||
method: 'POST',
|
||||
headers: ['Content-Type' => 'application/json'],
|
||||
body: '',
|
||||
);
|
||||
|
||||
$result = $this->middleware->processRequest($request);
|
||||
|
||||
self::assertSame([], $result->getAttribute('json_body'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalid_json_throws_validation_exception(): void
|
||||
{
|
||||
$request = TestHelper::createMockRequest(
|
||||
method: 'POST',
|
||||
headers: ['Content-Type' => 'application/json'],
|
||||
body: '{invalid json!!!',
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$this->expectExceptionMessageMatches('/Invalid JSON/');
|
||||
|
||||
$this->middleware->processRequest($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parses_json_with_charset_in_content_type(): void
|
||||
{
|
||||
$payload = ['key' => 'value'];
|
||||
$request = TestHelper::createMockRequest(
|
||||
method: 'PUT',
|
||||
headers: ['Content-Type' => 'application/json; charset=utf-8'],
|
||||
body: json_encode($payload),
|
||||
);
|
||||
|
||||
$result = $this->middleware->processRequest($request);
|
||||
|
||||
self::assertSame($payload, $result->getAttribute('json_body'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user