feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -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;
}
};
}
}